diff --git a/server/core/internal/assistant/block/block.go b/server/core/internal/assistant/block/block.go index 23708313..4b30e5ef 100644 --- a/server/core/internal/assistant/block/block.go +++ b/server/core/internal/assistant/block/block.go @@ -111,6 +111,55 @@ const ( BlockParamList Type = "split_doc_param_list" ) +// Label turns the type into a friendlier label for the confirm UI. +// Unknown types fall through verbatim. +func (t Type) Label() string { + switch t { + case BlockParagraph: + return "a paragraph" + case BlockHeading: + return "a heading" + case BlockBlockquote: + return "a blockquote" + case BlockBulletList: + return "a bullet list" + case BlockOrderedList: + return "an ordered list" + case BlockTaskList: + return "a task list" + case BlockCallout: + return "a callout" + case BlockCode: + return "a code block" + case BlockTitledCode: + return "a titled code block" + case BlockMermaid: + return "a mermaid diagram" + case BlockHorizontalRule: + return "a divider" + case BlockImage: + return "an image" + case BlockFile: + return "a file attachment" + case BlockFigma: + return "a figma embed" + case BlockMetric: + return "a metric" + case BlockMetricGrid: + return "a metric grid" + case BlockSplitDoc: + return "a split documentation block" + case BlockParamList: + return "a parameter list" + } + + if t == "" { + return "a block" + } + + return "a " + string(t) + " block" +} + // Block is the canonical representation of a single block as the AI // reads and writes it. Not every field is meaningful for every block // type: diff --git a/server/core/internal/assistant/block/block_test.go b/server/core/internal/assistant/block/block_test.go index 0289ef6a..54b99462 100644 --- a/server/core/internal/assistant/block/block_test.go +++ b/server/core/internal/assistant/block/block_test.go @@ -92,3 +92,39 @@ func Test_Block_CollectAttributeValues(t *testing.T) { }) } } + +func Test_Type_Label(t *testing.T) { + cc := map[string]struct { + Kind Type + Result string + }{ + "Paragraph": {Kind: "paragraph", Result: "a paragraph"}, + "Heading": {Kind: "heading", Result: "a heading"}, + "Blockquote": {Kind: "blockquote", Result: "a blockquote"}, + "Bullet list": {Kind: "bullet_list", Result: "a bullet list"}, + "Ordered list": {Kind: "ordered_list", Result: "an ordered list"}, + "Task list": {Kind: "task_list", Result: "a task list"}, + "Callout": {Kind: "callout", Result: "a callout"}, + "Code": {Kind: "code", Result: "a code block"}, + "Titled code": {Kind: "titled_code", Result: "a titled code block"}, + "Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"}, + "Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"}, + "Image": {Kind: "image", Result: "an image"}, + "File": {Kind: "file", Result: "a file attachment"}, + "Figma": {Kind: "figma", Result: "a figma embed"}, + "Metric": {Kind: "metric", Result: "a metric"}, + "Metric grid": {Kind: "metric_grid", Result: "a metric grid"}, + "Split doc": {Kind: "split_doc", Result: "a split documentation block"}, + "Param list": {Kind: "split_doc_param_list", Result: "a parameter list"}, + "Empty": {Kind: "", Result: "a block"}, + "Unknown": {Kind: "wibble", Result: "a wibble block"}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, c.Kind.Label()) + }) + } +} diff --git a/server/core/internal/assistant/edit/client.go b/server/core/internal/assistant/edit/client.go index 024461b9..5d5ba4f2 100644 --- a/server/core/internal/assistant/edit/client.go +++ b/server/core/internal/assistant/edit/client.go @@ -30,16 +30,6 @@ type Result struct { Errors []OpError `json:"errors"` } -// OpError describes one operation's failure on the Node side. -type OpError struct { - // Index is the position of the failing op in the request's - // operations array. - Index int `json:"index"` - - // Message is the short reason from Node. - Message string `json:"message"` -} - // Client posts batched operations to the Node hocuspocus service's // internal operations endpoint. The base URL points at the Node // service (e.g. http://auth-realtime:8081); endpoint replaces its diff --git a/server/core/internal/assistant/edit/error.go b/server/core/internal/assistant/edit/error.go new file mode 100644 index 00000000..03201743 --- /dev/null +++ b/server/core/internal/assistant/edit/error.go @@ -0,0 +1,48 @@ +package edit + +import ( + "fmt" + "strings" +) + +// OpError describes one operation's failure on the Node side. +type OpError struct { + // Index is the position of the failing op in the request's + // operations array. + Index int `json:"index"` + + // Message is the short reason from Node. + Message string `json:"message"` +} + +// describe rewrites the Node-side message into what the model can act +// on: a uid it holds no block for points at get_document, a reference +// inside the moved block says what to pick instead, and an operation +// kind is named as the tool the model knows it by. A message with no +// rewrite passes through as it is. +func (e OpError) describe() string { + for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} { + if uid, ok := strings.CutPrefix(e.Message, prefix); ok { + return fmt.Sprintf("no block with uid %s in this document; call get_document for the current uids", uid) + } + } + + if uid, ok := strings.CutPrefix(e.Message, "reference_uid is inside the moved block: "); ok { + return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid) + } + + return strings.ReplaceAll(e.Message, "update_text", "update_block_text") +} + +// JoinOpErrors renders per-operation failures as one message. The +// index is left out: a tool sends a single operation, so naming its +// position says nothing the reader can act on. +func JoinOpErrors(errs []OpError) string { + msgs := make([]string, 0, len(errs)) + + for _, e := range errs { + msgs = append(msgs, e.describe()) + } + + return strings.Join(msgs, "; ") +} diff --git a/server/core/internal/assistant/edit/error_test.go b/server/core/internal/assistant/edit/error_test.go new file mode 100644 index 00000000..970dbb81 --- /dev/null +++ b/server/core/internal/assistant/edit/error_test.go @@ -0,0 +1,58 @@ +package edit + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_OpError_describe(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Msg string + Result string + }{ + "Reference uid not found": { + Msg: "reference_uid not found: r1", + Result: "no block with uid r1 in this document; call get_document for the current uids", + }, + "Block uid not found": { + Msg: "block_uid not found: b1", + Result: "no block with uid b1 in this document; call get_document for the current uids", + }, + "Reference inside the moved block": { + Msg: "reference_uid is inside the moved block: r1", + Result: "reference block r1 sits inside the block being moved; choose a reference outside it", + }, + "Operation kind named as its tool": { + Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.", + Result: "update_block_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_block_text on the block holding the text.", + }, + "Unknown message passes through": { + Msg: "cannot move a block relative to itself: a", + Result: "cannot move a block relative to itself: a", + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, OpError{Message: c.Msg}.describe()) + }) + } +} + +func Test_JoinOpErrors(t *testing.T) { + t.Parallel() + + got := JoinOpErrors([]OpError{ + {Index: 0, Message: "block_uid not found: a"}, + {Index: 1, Message: "something else"}, + }) + + // each message is rewritten for the model and the index is left out, + // since a tool ships one operation. + assert.Equal(t, "no block with uid a in this document; call get_document for the current uids; something else", got) +} diff --git a/server/core/internal/assistant/tools/block.go b/server/core/internal/assistant/tools/block.go index 390bf905..517bd1a4 100644 --- a/server/core/internal/assistant/tools/block.go +++ b/server/core/internal/assistant/tools/block.go @@ -11,6 +11,7 @@ import ( "github.com/oxynote/oxynote/server/core/internal/assistant/edit" "github.com/oxynote/oxynote/server/core/internal/document" "github.com/oxynote/oxynote/server/core/pkg/errutil" + "github.com/oxynote/oxynote/server/core/pkg/strutil" "github.com/rs/xid" ) @@ -259,7 +260,7 @@ func (insertBlock) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameInsertBlock, err) } - return "Updating " + docLabel(doc), nil + return "Updating " + doc.Title(), nil } // Summary describes the insertion the model wants to make. @@ -275,17 +276,17 @@ func (insertBlock) Summary(inp DescribeInput) (ActionSummary, error) { return ActionSummary{}, fmt.Errorf("%s: fetch document: %w", NameInsertBlock, err) } - kind := blockKindLabel(in.Block.Type) + kind := in.Block.Type.Label() var summary string switch in.Position { case positionStart: - summary = fmt.Sprintf("Prepend %s to %s", kind, docLabel(doc)) + summary = fmt.Sprintf("Prepend %s to %s", kind, doc.Title()) case positionEnd: - summary = fmt.Sprintf("Append %s to %s", kind, docLabel(doc)) + summary = fmt.Sprintf("Append %s to %s", kind, doc.Title()) default: - summary = fmt.Sprintf("Insert %s %s a block in %s", kind, in.Position, docLabel(doc)) + summary = fmt.Sprintf("Insert %s %s a block in %s", kind, in.Position, doc.Title()) } return ActionSummary{ @@ -423,7 +424,7 @@ func (replaceBlock) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameReplaceBlock, err) } - return "Updating " + docLabel(doc), nil + return "Updating " + doc.Title(), nil } // Summary describes the replacement the model wants to make. @@ -443,7 +444,7 @@ func (replaceBlock) Summary(inp DescribeInput) (ActionSummary, error) { Tool: NameReplaceBlock, DocumentID: doc.ID, DocumentName: doc.DocumentName, - Summary: fmt.Sprintf("Replace a block in %s with %s", doc.DocumentName, blockKindLabel(in.Block.Type)), + Summary: fmt.Sprintf("Replace a block in %s with %s", doc.DocumentName, in.Block.Type.Label()), }, nil } @@ -547,7 +548,7 @@ func (updateBlockText) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameUpdateBlockText, err) } - return "Updating " + docLabel(doc), nil + return "Updating " + doc.Title(), nil } // Summary previews the text the model wants to write. @@ -558,7 +559,7 @@ func (updateBlockText) Summary(inp DescribeInput) (ActionSummary, error) { return ActionSummary{}, err } - preview := textPreview(in.Text, _maxPreviewLen) + preview := strutil.Preview(in.Text, _maxPreviewLen) doc, err := inp.FetchBranch(in.DocumentID, in.BranchID) if err != nil { @@ -567,7 +568,7 @@ func (updateBlockText) Summary(inp DescribeInput) (ActionSummary, error) { summary := fmt.Sprintf("Update a block in %s: %q", doc.DocumentName, preview) if preview == "" { - summary = "Update text of a block in " + docLabel(doc) + summary = "Update text of a block in " + doc.Title() } return ActionSummary{ @@ -678,7 +679,7 @@ func (updateBlockAttrs) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameUpdateBlockAttrs, err) } - return "Updating " + docLabel(doc), nil + return "Updating " + doc.Title(), nil } // Summary names the attributes the model wants to set. @@ -702,7 +703,7 @@ func (updateBlockAttrs) Summary(inp DescribeInput) (ActionSummary, error) { Tool: NameUpdateBlockAttrs, DocumentID: doc.ID, DocumentName: doc.DocumentName, - Summary: fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), docLabel(doc)), + Summary: fmt.Sprintf("Update block %s in %s", strings.Join(keys, ", "), doc.Title()), }, nil } @@ -810,7 +811,7 @@ func (deleteBlock) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameDeleteBlock, err) } - return "Updating " + docLabel(doc), nil + return "Updating " + doc.Title(), nil } // Summary describes the deletion the model wants to make. @@ -830,7 +831,7 @@ func (deleteBlock) Summary(inp DescribeInput) (ActionSummary, error) { Tool: NameDeleteBlock, DocumentID: doc.ID, DocumentName: doc.DocumentName, - Summary: "Delete a block in " + docLabel(doc), + Summary: "Delete a block in " + doc.Title(), }, nil } @@ -945,7 +946,7 @@ func (moveBlock) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameMoveBlock, err) } - return "Updating " + docLabel(doc), nil + return "Updating " + doc.Title(), nil } // Summary describes the move the model wants to make. @@ -965,7 +966,7 @@ func (moveBlock) Summary(inp DescribeInput) (ActionSummary, error) { Tool: NameMoveBlock, DocumentID: doc.ID, DocumentName: doc.DocumentName, - Summary: fmt.Sprintf("Move a block %s another block in %s", in.Position, docLabel(doc)), + Summary: fmt.Sprintf("Move a block %s another block in %s", in.Position, doc.Title()), }, nil } diff --git a/server/core/internal/assistant/tools/confirm.go b/server/core/internal/assistant/tools/confirm.go index a947c8f5..bb56a5c6 100644 --- a/server/core/internal/assistant/tools/confirm.go +++ b/server/core/internal/assistant/tools/confirm.go @@ -3,7 +3,6 @@ package tools import ( "context" "encoding/json" - "sync" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/tool" @@ -17,23 +16,14 @@ import ( // survives the checkpoint written when the turn is interrupted. const SessionKeyAutoApprove = "oxynote_assistant_auto_approve" -// _registerOnce guards the one-time registration of the types that -// cross an interrupt. Registering twice panics. -var _registerOnce sync.Once - -// registerConfirmTypes teaches the checkpoint serialiser about the -// values a paused write carries. A turn parked on a confirmation is -// written to Redis, and an unregistered type fails that write, which -// would surface as a failed turn rather than a prompt. -// -// The names are the identities those checkpoints are written under. -// Renaming one strands every turn already parked in Redis, so a rename -// is only free while nothing is deployed. -func registerConfirmTypes() { - _registerOnce.Do(func() { - schema.RegisterName[confirmState]("oxynote_assistant_confirm_state") - schema.RegisterName[ActionSummary]("oxynote_assistant_action_summary") - }) +// the checkpoint is gob-encoded, and both values sit behind interface +// fields in it: confirmState as the interrupt state, ActionSummary as +// the interrupt info. gob needs a name for a concrete type it meets +// through an interface, and a checkpoint already in Redis carries the +// name it was written with, so a rename fails to decode it. +func init() { + schema.RegisterName[confirmState]("oxynote_assistant_confirm_state") + schema.RegisterName[ActionSummary]("oxynote_assistant_action_summary") } // ActionSummary is the human-readable description of a pending write op diff --git a/server/core/internal/assistant/tools/document.go b/server/core/internal/assistant/tools/document.go index d343c0fe..07d830c3 100644 --- a/server/core/internal/assistant/tools/document.go +++ b/server/core/internal/assistant/tools/document.go @@ -9,6 +9,7 @@ import ( "github.com/guregu/null/v5" "github.com/oxynote/oxynote/server/core/internal/assistant/edit" "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/tag" "github.com/rs/xid" ) @@ -117,7 +118,7 @@ type getDocument struct { func (getDocument) Info() Info { return Info{ Name: NameGetDocument, - Description: "Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.", + Description: "Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, tagName, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.", Properties: map[string]any{ "document_id": map[string]any{"type": "string", "description": "The document id."}, "branch_id": map[string]any{"type": "string", "description": "The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write."}, @@ -139,7 +140,7 @@ func (getDocument) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameGetDocument, err) } - return "Reading " + docLabel(doc), nil + return "Reading " + doc.Title(), nil } // Execute fetches the branch and summarises its content. The document @@ -175,10 +176,14 @@ func (getDocument) Execute(inp Input) (string, error) { UpdatedAt: doc.UpdatedAt.UTC().Format(time.RFC3339), Branch: branchInfo{ID: doc.BranchID, Name: doc.BranchName, Protected: doc.Protected, Default: doc.Default}, Branches: make([]branchInfo, 0, len(branches)), - Tags: tagInfos(tags), + Tags: make([]tag.Summary, 0, len(tags)), Blocks: walkDocForAssistant(doc.Content.Content), } + for _, tg := range tags { + out.Tags = append(out.Tags, tg.Summary()) + } + for _, b := range branches { out.Branches = append(out.Branches, branchInfo{ ID: b.BranchID, @@ -219,7 +224,7 @@ type documentResult struct { Branches []branchInfo `json:"branches"` // Tags is every tag the branch read carries. - Tags []tagInfo `json:"tags"` + Tags []tag.Summary `json:"tags"` // Blocks is the branch's content, one row per block. Blocks []docSummaryEntry `json:"blocks"` diff --git a/server/core/internal/assistant/tools/document_test.go b/server/core/internal/assistant/tools/document_test.go index b273a9cd..0cf62faa 100644 --- a/server/core/internal/assistant/tools/document_test.go +++ b/server/core/internal/assistant/tools/document_test.go @@ -308,7 +308,7 @@ func Test_getDocument_Execute(t *testing.T) { DB: tagged, Args: `{` + targetArgs(_stubBranchID) + `}`, Contains: []string{ - `"tags":[{"id":"` + _testTagID.String() + `","name":"Production","color":"#22c55e"}]`, + `"tags":[{"id":"` + _testTagID.String() + `","tagName":"Production","color":"#22c55e","hidden":false}]`, }, }, "Root document omits the parent": { diff --git a/server/core/internal/assistant/tools/hook.go b/server/core/internal/assistant/tools/hook.go index 1574ff24..590b2c51 100644 --- a/server/core/internal/assistant/tools/hook.go +++ b/server/core/internal/assistant/tools/hook.go @@ -371,7 +371,7 @@ func (createHook) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameCreateHook, err) } - return "Adding a hook to " + docLabel(doc), nil + return "Adding a hook to " + doc.Title(), nil } // Summary names the hook type and where it goes. @@ -777,7 +777,7 @@ func describeHook(inp DescribeInput, name Name, ref hookRefArgs) (*document.Docu // hookLabel names a hook by its type, the document it is on and, when // anchored, its block, in the words the confirm card uses. func hookLabel(tp hook.Type, blockUID null.String, doc *document.Document) string { - label := tp.HumanizedString() + " hook on " + docLabel(doc) + label := tp.HumanizedString() + " hook on " + doc.Title() if blockUID.Valid { label += ", block " + blockUID.String diff --git a/server/core/internal/assistant/tools/input.go b/server/core/internal/assistant/tools/input.go index 767bb9a8..b63cdb6c 100644 --- a/server/core/internal/assistant/tools/input.go +++ b/server/core/internal/assistant/tools/input.go @@ -675,7 +675,7 @@ func (i *input) ApplyEdit(documentID, branchID xid.ID, ops []edit.Operation) err // marks the result isError, and the assistant sees a failure it can // correct rather than a success it will summarise. if res.Applied == 0 && len(res.Errors) > 0 { - return fmt.Errorf("applying edit: %s", joinOpErrors(res.Errors)) + return fmt.Errorf("applying edit: %s", edit.JoinOpErrors(res.Errors)) } if len(res.Errors) > 0 { diff --git a/server/core/internal/assistant/tools/tag.go b/server/core/internal/assistant/tools/tag.go index 630ecd05..f0a91843 100644 --- a/server/core/internal/assistant/tools/tag.go +++ b/server/core/internal/assistant/tools/tag.go @@ -489,8 +489,12 @@ func (assignTag) Info() Info { return Info{ Name: NameAssignTag, Description: "Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.", - Properties: branchTagProps(), - Required: []string{"document_id", "branch_id", "tag_id"}, + Properties: map[string]any{ + "document_id": map[string]any{"type": "string", "description": "The document id."}, + "branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."}, + "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."}, + }, + Required: []string{"document_id", "branch_id", "tag_id"}, } } @@ -512,7 +516,7 @@ func (assignTag) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameAssignTag, err) } - return "Tagging " + docLabel(doc), nil + return "Tagging " + doc.Title(), nil } // Summary names the tag and the document it goes on. @@ -537,7 +541,7 @@ func (assignTag) Summary(inp DescribeInput) (ActionSummary, error) { Tool: NameAssignTag, DocumentID: doc.ID, DocumentName: doc.DocumentName, - Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, docLabel(doc)), + Summary: fmt.Sprintf("Put tag %s on %s", tg.TagName, doc.Title()), }, nil } @@ -567,8 +571,12 @@ func (unassignTag) Info() Info { return Info{ Name: NameUnassignTag, Description: "Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.", - Properties: branchTagProps(), - Required: []string{"document_id", "branch_id", "tag_id"}, + Properties: map[string]any{ + "document_id": map[string]any{"type": "string", "description": "The document id."}, + "branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."}, + "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."}, + }, + Required: []string{"document_id", "branch_id", "tag_id"}, } } @@ -590,7 +598,7 @@ func (unassignTag) Title(inp DescribeInput) (string, error) { return "", fmt.Errorf("%s: fetch document: %w", NameUnassignTag, err) } - return "Untagging " + docLabel(doc), nil + return "Untagging " + doc.Title(), nil } // Summary names the tag and the document it comes off. @@ -615,7 +623,7 @@ func (unassignTag) Summary(inp DescribeInput) (ActionSummary, error) { Tool: NameUnassignTag, DocumentID: doc.ID, DocumentName: doc.DocumentName, - Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, docLabel(doc)), + Summary: fmt.Sprintf("Take tag %s off %s", tg.TagName, doc.Title()), }, nil } @@ -753,36 +761,3 @@ type movedTagResult struct { // SortIndex is the 0-based position it now has. SortIndex int `json:"sort_index"` } - -// tagInfo describes one tag a branch carries to the model. -type tagInfo struct { - // ID is the tag id, which is what a tool's tag_id takes. - ID xid.ID `json:"id"` - - // Name is the tag's display name. - Name string `json:"name"` - - // Color is the tag's colour as a hex triplet. - Color string `json:"color"` -} - -// tagInfos converts the tags a branch carries into the model's shape. -func tagInfos(tags []tag.Tag) []tagInfo { - out := make([]tagInfo, 0, len(tags)) - - for _, t := range tags { - out = append(out, tagInfo{ID: t.ID, Name: t.TagName, Color: t.Color}) - } - - return out -} - -// branchTagProps builds the argument schema assign_tag and unassign_tag -// share. -func branchTagProps() map[string]any { - return map[string]any{ - "document_id": map[string]any{"type": "string", "description": "The document id."}, - "branch_id": map[string]any{"type": "string", "description": "The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists."}, - "tag_id": map[string]any{"type": "string", "description": "The tag id, as list_tags or a document's tags report it."}, - } -} diff --git a/server/core/internal/assistant/tools/tag_test.go b/server/core/internal/assistant/tools/tag_test.go index 3d74be2e..5bc962bd 100644 --- a/server/core/internal/assistant/tools/tag_test.go +++ b/server/core/internal/assistant/tools/tag_test.go @@ -137,15 +137,6 @@ func Test_listTags_Execute(t *testing.T) { } } -func Test_tagInfos(t *testing.T) { - t.Parallel() - - assert.Equal(t, []tagInfo{}, tagInfos(nil)) - assert.Equal(t, []tagInfo{ - {ID: _testTagID, Name: _stubTagName, Color: _stubTagColor}, - }, tagInfos([]tag.Tag{{ID: _testTagID, TagName: _stubTagName, Color: _stubTagColor, OrganizationID: "org"}})) -} - func Test_createTagArgs_Validate(t *testing.T) { t.Parallel() @@ -597,16 +588,6 @@ func Test_branchTagArgs_Validate(t *testing.T) { }) } -func Test_branchTagProps(t *testing.T) { - t.Parallel() - - props := branchTagProps() - - assert.Contains(t, props, "document_id") - assert.Contains(t, props, "branch_id") - assert.Contains(t, props, "tag_id") -} - func Test_assignTag_Info(t *testing.T) { t.Parallel() diff --git a/server/core/internal/assistant/tools/testdata/tool_schemas.golden b/server/core/internal/assistant/tools/testdata/tool_schemas.golden index 699f74a2..86481acc 100644 --- a/server/core/internal/assistant/tools/testdata/tool_schemas.golden +++ b/server/core/internal/assistant/tools/testdata/tool_schemas.golden @@ -1 +1 @@ -{"assign_tag":{"desc":"Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"assign_tag"},"create_document":{"desc":"Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.","has_params_one_of":true,"json_schema":{"properties":{"icon":{"description":"Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty.","type":"string"},"name":{"description":"Display name for the new document.","type":"string"},"parent_id":{"description":"Optional parent document id. Omit to create at the org root.","type":"string"}},"required":["name"],"type":"object"},"name":"create_document"},"create_hook":{"desc":"Add a freshness hook to one branch of a document and return it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole.","type":"string"},"branch_id":{"description":"The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"The RFC 3339 timestamp at which the reminder is due.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"title":"scheduled-reminder","type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The repository branch to follow.","type":"string"},"paths":{"description":"The repository paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository to follow, as owner/name.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"title":"github-tracking","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The web address to watch for changes.","type":"string"}},"required":["type","url"],"title":"url-watcher","type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The container image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"title":"container-image-watcher","type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","branch_id","settings"],"type":"object"},"name":"create_hook"},"create_tag":{"desc":"Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a malformed colour or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"The tag's colour as a hex triplet with the leading hash, such as \"#22c55e\".","type":"string"},"name":{"description":"Display name for the new tag; unique within the organisation.","type":"string"}},"required":["name","color"],"type":"object"},"name":"create_tag"},"delete_block":{"desc":"Delete one block by uid, including anything nested inside it. Its comments, hooks and files go with it and cannot be restored, so use move_block when the aim is to reorder and update_block_text when the aim is new wording. Returns {deleted: uid}.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to delete.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"delete_block"},"delete_document":{"desc":"Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id to delete.","type":"string"}},"required":["document_id"],"type":"object"},"name":"delete_document"},"delete_hook":{"desc":"Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"delete_hook"},"delete_tag":{"desc":"Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"tag_id":{"description":"The id of the tag to delete.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"delete_tag"},"get_document":{"desc":"Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, name, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"get_document"},"get_prometheus_metadata":{"desc":"List the metrics a Prometheus data source exposes, with each metric's type, help text and unit. Use it to find the metric names to write a PromQL query against instead of guessing them.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_prometheus_metadata"},"get_sql_metadata":{"desc":"List the tables and their columns in a PostgreSQL, MariaDB or MySQL data source, plus the default schema. Read it before writing a query so the table and column names are the real ones.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_sql_metadata"},"get_sql_query_labels":{"desc":"Run a SQL query limited to one row and return its string columns with an example value each, which are the columns a chart would treat as series labels. Use it to check what a query returns before charting it; it is cheaper than query_sql.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to probe. $__ macros are expanded as they are for a metric block.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"get_sql_query_labels"},"insert_block":{"desc":"Insert one canonical block into a document. position start or end puts it at the document's start or end; before or after puts it beside the block reference_block_uid names, which stays in place. The block has to be legal where it lands: the document root takes every type except titled_code, metric and split_doc_param_list, and a reference inside a split_doc or metric_grid takes only what that container holds. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"anyOf":[{"additionalProperties":false,"properties":{"children":{"description":"Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"paragraph"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"paragraph","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"level":{"description":"The heading level.","enum":[1,2,3],"type":"integer"}},"required":["level"],"type":"object"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"heading"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"heading","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The blocks it quotes, of any type legal at the document root. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"blockquote"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"blockquote","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"bullet_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"bullet_list","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"ordered_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"ordered_list","type":"object"},{"additionalProperties":false,"properties":{"task_items":{"description":"The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.","items":{"properties":{"block":{"type":"object"},"checked":{"type":"boolean"}},"required":["block"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"task_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","task_items"],"title":"task_list","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"icon":{"description":"Iconify identifier; defaults to lucide:text.","type":"string"}},"type":"object"},"items":{"description":"The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"callout"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"callout","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"}},"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"code","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"},"title":{"description":"Plain-text title of the block.","type":"string"}},"required":["title"],"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"titled_code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"titled_code","type":"object"},{"additionalProperties":false,"properties":{"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"mermaid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"mermaid","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"horizontal_rule"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"horizontal_rule","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"alt":{"description":"Alternative text.","type":"string"},"src":{"description":"The image address.","type":"string"},"title":{"description":"Caption.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"image"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"image","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"height":{"description":"Display height in pixels.","type":"integer"},"src":{"description":"The Figma frame address.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"figma"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"figma","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"description":"The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.","properties":{"refreshInterval":{"description":"How often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"The window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"The unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"The chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"width":{"description":"The chart's size.","enum":["compact","standard","wide"],"type":"string"}},"type":"object"},"type":{"const":"metric"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"metric","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The metric blocks it holds.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"metric_grid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"metric_grid","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"inversed":{"description":"Flips the two sides.","type":"boolean"}},"type":"object"},"left":{"description":"The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.","items":{"type":"object"},"minItems":1,"type":"array"},"right":{"description":"The example side. Holds titled_code or metric only, and is the one place titled_code is legal.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","left","right"],"title":"split_doc","type":"object"},{"additionalProperties":false,"properties":{"header":{"description":"The section header, as plain text.","type":"string"},"params":{"description":"The rows.","items":{"properties":{"description":{"description":"The description, in minimal markdown.","type":"string"},"name":{"description":"The parameter name, as plain text.","type":"string"},"type":{"description":"The parameter type, as plain text.","type":"string"}},"required":["name"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc_param_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","header","params"],"title":"split_doc_param_list","type":"object"}],"description":"A canonical block. Its type decides which fields it takes."},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Where the block lands: before or after the reference block, or at the start or end of the document.","enum":["before","after","start","end"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to insert beside. Required with before and after; leave it out with start and end.","type":"string"}},"required":["document_id","branch_id","position","block"],"type":"object"},"name":"insert_block"},"list_data_sources":{"desc":"List the organisation's data sources. Returns id, name, type (prometheus, postgresql, mariadb, mysql) and connection status. Start here: every other data-source tool takes an id from this list, and the type decides which of them serves it.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_data_sources"},"list_documents":{"desc":"List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.","has_params_one_of":true,"json_schema":{"properties":{"parent_id":{"description":"Optional. Return only the direct children of this document. Omit for the full tree.","type":"string"}},"type":"object"},"name":"list_documents"},"list_hooks":{"desc":"List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"list_hooks"},"list_prometheus_label_names":{"desc":"List the label names present in a Prometheus data source, optionally only those on the series the matchers select. Pair it with list_prometheus_label_values to build a filtered PromQL query.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"list_prometheus_label_names"},"list_prometheus_label_values":{"desc":"List the values a label takes in a Prometheus data source, optionally only on the series the matchers select. Use it to discover the concrete label values a query should filter on.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"label":{"description":"The label whose values to list, e.g. \"job\".","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","label"],"type":"object"},"name":"list_prometheus_label_values"},"list_prometheus_series":{"desc":"List the series matching a set of selectors in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric actually has before querying it.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","matchers"],"type":"object"},"name":"list_prometheus_series"},"list_tags":{"desc":"List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_tags"},"move_block":{"desc":"Move an existing block before or after another block in the same document. The block keeps its uid, attrs and nested content, so comments, hooks and files attached to it stay attached; deleting it and inserting a copy would lose them. The landing spot has to accept the block's type, by the same rule insert_block applies. Returns the summary rows of the moved block and everything nested in it.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to move.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Landing side relative to the reference block.","enum":["before","after"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to move relative to.","type":"string"}},"required":["document_id","branch_id","block_uid","position","reference_block_uid"],"type":"object"},"name":"move_block"},"move_tag":{"desc":"Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.","has_params_one_of":true,"json_schema":{"properties":{"sort_index":{"description":"The 0-based position the tag should end up at among the organisation's tags.","type":"integer"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id","sort_index"],"type":"object"},"name":"move_tag"},"query_prometheus":{"desc":"Run a PromQL range query against a Prometheus data source over the given window. Returns the raw result by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The PromQL expression to run.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_prometheus"},"query_sql":{"desc":"Run a read-only query against a PostgreSQL, MariaDB or MySQL data source. Returns columns and rows by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block. $__ macros ($__timeFilter, $__timeGroupAlias and the rest) are expanded against the window.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to run. For a chart, select a time column aliased \"time\" plus one or more numeric columns.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_sql"},"read_block":{"desc":"Return the full canonical content of one block by uid, including any nested children. Use it only when get_document's rows are not enough: to edit a split_doc, a nested list or a split_doc_param_list, whose inner structure has to be written back in full. Fails when the uid is not in the document.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The block uid to fetch.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"read_block"},"read_tool_output":{"desc":"Retrieve the full output of an earlier tool call that was too large to keep in the conversation. Pass the path shown in the truncation notice.","has_params_one_of":true,"json_schema":{"properties":{"file_path":{"description":"The stored output path from the truncation notice.","type":"string"}},"required":["file_path"],"type":"object"},"name":"read_tool_output"},"replace_block":{"desc":"Replace a block by uid with a new block in the same position. The old block's uid, content and children are all gone unless the new block carries them, so use it to change a block's type or its whole structure. For a wording change use update_block_text, and for an attribute change update_block_attrs; both keep the uid, which comments, hooks and files hang off. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"anyOf":[{"additionalProperties":false,"properties":{"children":{"description":"Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"paragraph"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"paragraph","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"level":{"description":"The heading level.","enum":[1,2,3],"type":"integer"}},"required":["level"],"type":"object"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"heading"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"heading","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The blocks it quotes, of any type legal at the document root. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"blockquote"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"blockquote","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"bullet_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"bullet_list","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"ordered_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"ordered_list","type":"object"},{"additionalProperties":false,"properties":{"task_items":{"description":"The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.","items":{"properties":{"block":{"type":"object"},"checked":{"type":"boolean"}},"required":["block"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"task_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","task_items"],"title":"task_list","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"icon":{"description":"Iconify identifier; defaults to lucide:text.","type":"string"}},"type":"object"},"items":{"description":"The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"callout"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"callout","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"}},"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"code","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"},"title":{"description":"Plain-text title of the block.","type":"string"}},"required":["title"],"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"titled_code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"titled_code","type":"object"},{"additionalProperties":false,"properties":{"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"mermaid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"mermaid","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"horizontal_rule"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"horizontal_rule","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"alt":{"description":"Alternative text.","type":"string"},"src":{"description":"The image address.","type":"string"},"title":{"description":"Caption.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"image"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"image","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"height":{"description":"Display height in pixels.","type":"integer"},"src":{"description":"The Figma frame address.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"figma"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"figma","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"description":"The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.","properties":{"refreshInterval":{"description":"How often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"The window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"The unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"The chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"width":{"description":"The chart's size.","enum":["compact","standard","wide"],"type":"string"}},"type":"object"},"type":{"const":"metric"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"metric","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The metric blocks it holds.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"metric_grid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"metric_grid","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"inversed":{"description":"Flips the two sides.","type":"boolean"}},"type":"object"},"left":{"description":"The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.","items":{"type":"object"},"minItems":1,"type":"array"},"right":{"description":"The example side. Holds titled_code or metric only, and is the one place titled_code is legal.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","left","right"],"title":"split_doc","type":"object"},{"additionalProperties":false,"properties":{"header":{"description":"The section header, as plain text.","type":"string"},"params":{"description":"The rows.","items":{"properties":{"description":{"description":"The description, in minimal markdown.","type":"string"},"name":{"description":"The parameter name, as plain text.","type":"string"},"type":{"description":"The parameter type, as plain text.","type":"string"}},"required":["name"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc_param_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","header","params"],"title":"split_doc_param_list","type":"object"}],"description":"A canonical block. Its type decides which fields it takes."},"block_uid":{"description":"The uid of the block being replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","block"],"type":"object"},"name":"replace_block"},"reset_hook":{"desc":"Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"reset_hook"},"search_documents":{"desc":"Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.","has_params_one_of":true,"json_schema":{"properties":{"limit":{"description":"Maximum number of hits to return. Defaults to 20; cap is 50.","type":"integer"},"query":{"description":"The full-text search query (typo-tolerant, matches block text).","type":"string"}},"required":["query"],"type":"object"},"name":"search_documents"},"unassign_tag":{"desc":"Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"unassign_tag"},"update_block_attrs":{"desc":"Set or override named attributes on an existing block, such as a heading's level or a callout's icon. Attributes not mentioned are kept and uid cannot change. Values are validated for the block's type, so a level outside 1 to 3 or a metric width other than compact, standard or wide is rejected. Use replace_block when the type itself has to change. Returns the summary rows of the block and everything nested in it, attrs as they now stand.","has_params_one_of":true,"json_schema":{"properties":{"attrs":{"description":"Attribute keys and values to set (e.g. {\"level\": 2}, {\"icon\": \"lucide:warning\"}).","type":"object"},"block_uid":{"description":"The uid of the block whose attrs should be updated.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","attrs"],"type":"object"},"name":"update_block_attrs"},"update_block_text":{"desc":"Replace the inline text of one text-bearing block: paragraph, heading, blockquote, code, titled_code, mermaid, or a callout written with text. Type, attrs and uid are kept, so this is the tool for wording changes. Text follows the canonical markdown subset (**bold**, *italic*, _underline_, ~~strike~~, backtick code, [label](url)), and is raw in code, titled_code and mermaid. One block is one paragraph; to add a paragraph, insert a block instead. Returns the block's summary row with the new text.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block whose text should be replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"text":{"description":"New inline text in canonical markdown.","type":"string"}},"required":["document_id","branch_id","block_uid","text"],"type":"object"},"name":"update_block_text"},"update_document":{"desc":"Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"icon":{"description":"Optional. The new icon identifier; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"parent_id":{"description":"Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is.","type":"string"}},"required":["document_id"],"type":"object"},"name":"update_document"},"update_hook":{"desc":"Replace the settings of a hook and reset its score and state, returning it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. The hook keeps its type, so settings.type is the type list_hooks reports for it; to change the type, delete the hook and create another.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"The RFC 3339 timestamp at which the reminder is due.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"title":"scheduled-reminder","type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The repository branch to follow.","type":"string"},"paths":{"description":"The repository paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository to follow, as owner/name.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"title":"github-tracking","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The web address to watch for changes.","type":"string"}},"required":["type","url"],"title":"url-watcher","type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The container image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"title":"container-image-watcher","type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","hook_id","settings"],"type":"object"},"name":"update_hook"},"update_tag":{"desc":"Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"Optional. The new colour as a hex triplet with the leading hash; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"update_tag"}} \ No newline at end of file +{"assign_tag":{"desc":"Put a tag on one branch of a document, so the document is listed under the tag in the sidebar when the branch is its default one. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from list_tags. A tag the branch already carries is left as it is, and a hidden tag can still be assigned. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"assign_tag"},"create_document":{"desc":"Create a new document and return {document_id, branch_id}, the branch_id being its default branch. The document starts with a single empty paragraph, so follow up with insert_block calls (position end, that branch_id) in the same turn to fill it. Omit parent_id to create it at the organisation root.","has_params_one_of":true,"json_schema":{"properties":{"icon":{"description":"Iconify identifier, as \"collection:name\". The product's own icons are MingCute fills (e.g. \"mingcute:file-code-fill\"); prefer one so the document matches the rest of the sidebar. Defaults to \"mingcute:document-2-fill\" when empty.","type":"string"},"name":{"description":"Display name for the new document.","type":"string"},"parent_id":{"description":"Optional parent document id. Omit to create at the org root.","type":"string"}},"required":["name"],"type":"object"},"name":"create_document"},"create_hook":{"desc":"Add a freshness hook to one branch of a document and return it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. block_uid anchors the hook to a block; a uid the branch does not hold is refused. A new hook starts at score 100.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"Optional. The uid of a block on the branch to anchor the hook to, from get_document; omit to put the hook on the document as a whole.","type":"string"},"branch_id":{"description":"The id of the branch the hook goes on: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"The RFC 3339 timestamp at which the reminder is due.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"title":"scheduled-reminder","type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The repository branch to follow.","type":"string"},"paths":{"description":"The repository paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository to follow, as owner/name.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"title":"github-tracking","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The web address to watch for changes.","type":"string"}},"required":["type","url"],"title":"url-watcher","type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The container image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"title":"container-image-watcher","type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","branch_id","settings"],"type":"object"},"name":"create_hook"},"create_tag":{"desc":"Create a tag and return {tag_id}. name is the display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a malformed colour or a name already in use is refused. The new tag lands last in the sidebar order, so use move_tag to place it, and assign_tag to put it on a document branch.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"The tag's colour as a hex triplet with the leading hash, such as \"#22c55e\".","type":"string"},"name":{"description":"Display name for the new tag; unique within the organisation.","type":"string"}},"required":["name","color"],"type":"object"},"name":"create_tag"},"delete_block":{"desc":"Delete one block by uid, including anything nested inside it. Its comments, hooks and files go with it and cannot be restored, so use move_block when the aim is to reorder and update_block_text when the aim is new wording. Returns {deleted: uid}.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to delete.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"delete_block"},"delete_document":{"desc":"Delete a document and every document nested under it. The whole subtree goes and cannot be restored, so check the tree with list_documents first, and use update_document when the aim is to relocate rather than remove. Returns {document_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id to delete.","type":"string"}},"required":["document_id"],"type":"object"},"name":"delete_document"},"delete_hook":{"desc":"Delete a hook, tearing down whatever it watches outside the document; it cannot be restored, so use reset_hook when the aim is to clear what it flagged rather than stop watching. Returns {hook_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"delete_hook"},"delete_tag":{"desc":"Delete a tag. Every document carrying it loses it, on every branch, and the tag cannot be restored; use unassign_tag when the aim is to take it off one document rather than remove it. Returns {tag_id, deleted}.","has_params_one_of":true,"json_schema":{"properties":{"tag_id":{"description":"The id of the tag to delete.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"delete_tag"},"get_document":{"desc":"Read one document on one branch: its name, icon, parent_id, protected flag and updated_at, the branch read and every branch the document has (each with the id a branch_id argument takes), the tags the branch carries as [{id, tagName, color}], followed by one row per block with the block's uid, kind, flattened text, depth, parent_uid and the few attrs that matter for reading (heading level, callout icon, code language, task checked). Use it as the way to read a document before editing it; branch_id is the default_branch_id a listing or search hit carries, or any id from branches. A protected branch can be read but refuses every write, so pick an unprotected branch id from branches to write to. Rows marked has_children hold nested blocks the rows do not list, and read_block returns those when you need them.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"get_document"},"get_prometheus_metadata":{"desc":"List the metrics a Prometheus data source exposes, with each metric's type, help text and unit. Use it to find the metric names to write a PromQL query against instead of guessing them.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_prometheus_metadata"},"get_sql_metadata":{"desc":"List the tables and their columns in a PostgreSQL, MariaDB or MySQL data source, plus the default schema. Read it before writing a query so the table and column names are the real ones.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"get_sql_metadata"},"get_sql_query_labels":{"desc":"Run a SQL query limited to one row and return its string columns with an example value each, which are the columns a chart would treat as series labels. Use it to check what a query returns before charting it; it is cheaper than query_sql.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to probe. $__ macros are expanded as they are for a metric block.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"get_sql_query_labels"},"insert_block":{"desc":"Insert one canonical block into a document. position start or end puts it at the document's start or end; before or after puts it beside the block reference_block_uid names, which stays in place. The block has to be legal where it lands: the document root takes every type except titled_code, metric and split_doc_param_list, and a reference inside a split_doc or metric_grid takes only what that container holds. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"anyOf":[{"additionalProperties":false,"properties":{"children":{"description":"Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"paragraph"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"paragraph","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"level":{"description":"The heading level.","enum":[1,2,3],"type":"integer"}},"required":["level"],"type":"object"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"heading"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"heading","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The blocks it quotes, of any type legal at the document root. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"blockquote"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"blockquote","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"bullet_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"bullet_list","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"ordered_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"ordered_list","type":"object"},{"additionalProperties":false,"properties":{"task_items":{"description":"The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.","items":{"properties":{"block":{"type":"object"},"checked":{"type":"boolean"}},"required":["block"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"task_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","task_items"],"title":"task_list","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"icon":{"description":"Iconify identifier; defaults to lucide:text.","type":"string"}},"type":"object"},"items":{"description":"The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"callout"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"callout","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"}},"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"code","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"},"title":{"description":"Plain-text title of the block.","type":"string"}},"required":["title"],"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"titled_code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"titled_code","type":"object"},{"additionalProperties":false,"properties":{"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"mermaid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"mermaid","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"horizontal_rule"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"horizontal_rule","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"alt":{"description":"Alternative text.","type":"string"},"src":{"description":"The image address.","type":"string"},"title":{"description":"Caption.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"image"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"image","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"height":{"description":"Display height in pixels.","type":"integer"},"src":{"description":"The Figma frame address.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"figma"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"figma","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"description":"The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.","properties":{"refreshInterval":{"description":"How often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"The window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"The unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"The chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"width":{"description":"The chart's size.","enum":["compact","standard","wide"],"type":"string"}},"type":"object"},"type":{"const":"metric"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"metric","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The metric blocks it holds.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"metric_grid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"metric_grid","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"inversed":{"description":"Flips the two sides.","type":"boolean"}},"type":"object"},"left":{"description":"The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.","items":{"type":"object"},"minItems":1,"type":"array"},"right":{"description":"The example side. Holds titled_code or metric only, and is the one place titled_code is legal.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","left","right"],"title":"split_doc","type":"object"},{"additionalProperties":false,"properties":{"header":{"description":"The section header, as plain text.","type":"string"},"params":{"description":"The rows.","items":{"properties":{"description":{"description":"The description, in minimal markdown.","type":"string"},"name":{"description":"The parameter name, as plain text.","type":"string"},"type":{"description":"The parameter type, as plain text.","type":"string"}},"required":["name"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc_param_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","header","params"],"title":"split_doc_param_list","type":"object"}],"description":"A canonical block. Its type decides which fields it takes."},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Where the block lands: before or after the reference block, or at the start or end of the document.","enum":["before","after","start","end"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to insert beside. Required with before and after; leave it out with start and end.","type":"string"}},"required":["document_id","branch_id","position","block"],"type":"object"},"name":"insert_block"},"list_data_sources":{"desc":"List the organisation's data sources. Returns id, name, type (prometheus, postgresql, mariadb, mysql) and connection status. Start here: every other data-source tool takes an id from this list, and the type decides which of them serves it.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_data_sources"},"list_documents":{"desc":"List the organisation's documents as a tree of {id, name, default_branch_id, children}; default_branch_id is what get_document and the block tools take as branch_id for the document's default branch. Use it to find a document by name or to see what sits under a parent; use search_documents when you are looking for content rather than a title. Pass parent_id to get only that document's direct children, or omit it for the whole organisation.","has_params_one_of":true,"json_schema":{"properties":{"parent_id":{"description":"Optional. Return only the direct children of this document. Omit for the full tree.","type":"string"}},"type":"object"},"name":"list_documents"},"list_hooks":{"desc":"List the freshness hooks on one branch of a document as [{id, type, block_uid, settings, score, state, created_at, updated_at}]. A hook watches something outside the document and lowers its score as that thing changes or a date approaches; block_uid names the block it is anchored to, or is null for a hook on the document as a whole. Use it to learn the id that update_hook, reset_hook and delete_hook take as hook_id. document_id and branch_id name the branch the way the content tools do.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch whose hooks to list: a document's default_branch_id from list_documents, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id"],"type":"object"},"name":"list_hooks"},"list_prometheus_label_names":{"desc":"List the label names present in a Prometheus data source, optionally only those on the series the matchers select. Pair it with list_prometheus_label_values to build a filtered PromQL query.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id"],"type":"object"},"name":"list_prometheus_label_names"},"list_prometheus_label_values":{"desc":"List the values a label takes in a Prometheus data source, optionally only on the series the matchers select. Use it to discover the concrete label values a query should filter on.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"label":{"description":"The label whose values to list, e.g. \"job\".","type":"string"},"matchers":{"description":"Optional. PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","label"],"type":"object"},"name":"list_prometheus_label_values"},"list_prometheus_series":{"desc":"List the series matching a set of selectors in a Prometheus data source, each as its full label set. Use it to see which label combinations a metric actually has before querying it.","has_params_one_of":true,"json_schema":{"properties":{"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"matchers":{"description":"PromQL series selectors, e.g. [\"up\", \"{job=\\\"api\\\"}\"].","items":{"type":"string"},"type":"array"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","matchers"],"type":"object"},"name":"list_prometheus_series"},"list_tags":{"desc":"List the organisation's tags in their sidebar order as [{id, name, color, hidden, documents}], where documents is [{id, name, default_branch_id}] for every document whose default branch carries the tag. Use it to find a tag by name before assigning it, and to learn the id that update_tag, delete_tag, move_tag, assign_tag and unassign_tag take as tag_id. A tag assigned to a non-default branch shows under tags on get_document for that branch but not here. hidden says whether the current user keeps the tag out of their own sidebar; it does not stop the tag being assigned.","has_params_one_of":true,"json_schema":{"properties":{},"type":"object"},"name":"list_tags"},"move_block":{"desc":"Move an existing block before or after another block in the same document. The block keeps its uid, attrs and nested content, so comments, hooks and files attached to it stay attached; deleting it and inserting a copy would lose them. The landing spot has to accept the block's type, by the same rule insert_block applies. Returns the summary rows of the moved block and everything nested in it.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block to move.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"position":{"description":"Landing side relative to the reference block.","enum":["before","after"],"type":"string"},"reference_block_uid":{"description":"The uid of the block to move relative to.","type":"string"}},"required":["document_id","branch_id","block_uid","position","reference_block_uid"],"type":"object"},"name":"move_block"},"move_tag":{"desc":"Move a tag to a position in the sidebar order, which is the order list_tags returns. sort_index is the 0-based position the tag should end up at, so 0 puts it first, and a position past the last tag is refused; the other tags keep their relative order. Returns {tag_id, sort_index}.","has_params_one_of":true,"json_schema":{"properties":{"sort_index":{"description":"The 0-based position the tag should end up at among the organisation's tags.","type":"integer"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id","sort_index"],"type":"object"},"name":"move_tag"},"query_prometheus":{"desc":"Run a PromQL range query against a Prometheus data source over the given window. Returns the raw result by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The PromQL expression to run.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_prometheus"},"query_sql":{"desc":"Run a read-only query against a PostgreSQL, MariaDB or MySQL data source. Returns columns and rows by default; with chart_type set it instead describes what a metric block would render, which is how to check a query before writing it into a block. $__ macros ($__timeFilter, $__timeGroupAlias and the rest) are expanded against the window.","has_params_one_of":true,"json_schema":{"properties":{"chart_type":{"description":"Optional. One of line_chart, bar_chart, gauge_chart. When set, the result describes what the metric block would draw (render status, series count, and each series' labels, point count and endpoints) instead of the raw data. Use it to check a query before putting it in a metric block; omit it when you need the values themselves.","type":"string"},"data_source_id":{"description":"The data source id, from list_data_sources.","type":"string"},"from":{"description":"Optional. Range start as an RFC3339 timestamp. Defaults to an hour before 'to'.","type":"string"},"query":{"description":"The SQL query to run. For a chart, select a time column aliased \"time\" plus one or more numeric columns.","type":"string"},"to":{"description":"Optional. Range end as an RFC3339 timestamp. Defaults to now.","type":"string"}},"required":["data_source_id","query"],"type":"object"},"name":"query_sql"},"read_block":{"desc":"Return the full canonical content of one block by uid, including any nested children. Use it only when get_document's rows are not enough: to edit a split_doc, a nested list or a split_doc_param_list, whose inner structure has to be written back in full. Fails when the uid is not in the document.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The block uid to fetch.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The document id.","type":"string"}},"required":["document_id","branch_id","block_uid"],"type":"object"},"name":"read_block"},"read_tool_output":{"desc":"Retrieve the full output of an earlier tool call that was too large to keep in the conversation. Pass the path shown in the truncation notice.","has_params_one_of":true,"json_schema":{"properties":{"file_path":{"description":"The stored output path from the truncation notice.","type":"string"}},"required":["file_path"],"type":"object"},"name":"read_tool_output"},"replace_block":{"desc":"Replace a block by uid with a new block in the same position. The old block's uid, content and children are all gone unless the new block carries them, so use it to change a block's type or its whole structure. For a wording change use update_block_text, and for an attribute change update_block_attrs; both keep the uid, which comments, hooks and files hang off. Returns the summary rows of the new block and everything nested in it, uids included, with depth counted from the block itself.","has_params_one_of":true,"json_schema":{"properties":{"block":{"anyOf":[{"additionalProperties":false,"properties":{"children":{"description":"Only when the paragraph is a list or task-list entry: the blocks indented under it, each a paragraph, bullet_list, ordered_list or task_list.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"paragraph"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"paragraph","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"level":{"description":"The heading level.","enum":[1,2,3],"type":"integer"}},"required":["level"],"type":"object"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one.","type":"string"},"type":{"const":"heading"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"heading","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The blocks it quotes, of any type legal at the document root. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"blockquote"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"blockquote","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"bullet_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"bullet_list","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The entries, each a paragraph block. Blocks nested under an entry go in that paragraph's children.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"ordered_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"ordered_list","type":"object"},{"additionalProperties":false,"properties":{"task_items":{"description":"The rows, each a checked flag and a paragraph block. Blocks nested under a row go in that paragraph's children.","items":{"properties":{"block":{"type":"object"},"checked":{"type":"boolean"}},"required":["block"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"task_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","task_items"],"title":"task_list","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"icon":{"description":"Iconify identifier; defaults to lucide:text.","type":"string"}},"type":"object"},"items":{"description":"The blocks it holds, each a paragraph, bullet_list, ordered_list or task_list. Use text or items, not both.","items":{"type":"object"},"type":"array"},"text":{"description":"Inline text in minimal markdown. One block is one paragraph; a newline inside text does not start a new one. Use text or items, not both.","type":"string"},"type":{"const":"callout"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"callout","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"}},"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"code","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"language":{"description":"Optional; leave empty rather than guess.","type":"string"},"title":{"description":"Plain-text title of the block.","type":"string"}},"required":["title"],"type":"object"},"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"titled_code"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"titled_code","type":"object"},{"additionalProperties":false,"properties":{"text":{"description":"Raw source; no markdown.","type":"string"},"type":{"const":"mermaid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"mermaid","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"horizontal_rule"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"horizontal_rule","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"alt":{"description":"Alternative text.","type":"string"},"src":{"description":"The image address.","type":"string"},"title":{"description":"Caption.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"image"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"image","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"height":{"description":"Display height in pixels.","type":"integer"},"src":{"description":"The Figma frame address.","type":"string"},"width":{"description":"Display width in pixels.","type":"integer"}},"required":["src"],"type":"object"},"type":{"const":"figma"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","attrs"],"title":"figma","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"description":"The chart configuration: the data source, the queries, the window and the display settings, as the block model describes them. Legal only inside a metric_grid or on a split_doc right side.","properties":{"refreshInterval":{"description":"How often the chart re-queries.","enum":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"type":"string"},"simulationPreset":{"description":"Draws this generated series in place of the query's own result, for a block documenting a metric that has no real data yet. Omit it to chart the query.","enum":["cpu_usage","memory_usage","disk_usage","http_requests","http_latency","error_rate"],"type":"string"},"timeRange":{"description":"The window queried.","enum":["last_5_minutes","last_15_minutes","last_30_minutes","last_1_hour","last_3_hours","last_6_hours","last_12_hours","last_24_hours","last_2_days","last_7_days","last_30_days","last_90_days","last_6_months","last_1_year","last_2_years","last_5_years","today","yesterday","today_so_far","this_week","this_week_so_far","this_month","this_month_so_far","this_year","this_year_so_far","previous_week","previous_month","previous_year"],"type":"string"},"unitType":{"description":"The unit values are read in. With custom, put the label in unitCustom.","enum":["custom","nanoseconds","microseconds","milliseconds","seconds","minutes","hours","days","bytes","kilobytes","megabytes","gigabytes","terabytes","bits","kilobits","megabits","gigabits","terabits","percent0to100","percent0to1"],"type":"string"},"visualizationType":{"description":"The chart kind.","enum":["line_chart","bar_chart","gauge_chart"],"type":"string"},"width":{"description":"The chart's size.","enum":["compact","standard","wide"],"type":"string"}},"type":"object"},"type":{"const":"metric"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type"],"title":"metric","type":"object"},{"additionalProperties":false,"properties":{"items":{"description":"The metric blocks it holds.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"metric_grid"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","items"],"title":"metric_grid","type":"object"},{"additionalProperties":false,"properties":{"attrs":{"properties":{"inversed":{"description":"Flips the two sides.","type":"boolean"}},"type":"object"},"left":{"description":"The concept side. Must start with a level-1 heading; then paragraphs, lists and callouts; then any split_doc_param_list, which is legal only here.","items":{"type":"object"},"minItems":1,"type":"array"},"right":{"description":"The example side. Holds titled_code or metric only, and is the one place titled_code is legal.","items":{"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","left","right"],"title":"split_doc","type":"object"},{"additionalProperties":false,"properties":{"header":{"description":"The section header, as plain text.","type":"string"},"params":{"description":"The rows.","items":{"properties":{"description":{"description":"The description, in minimal markdown.","type":"string"},"name":{"description":"The parameter name, as plain text.","type":"string"},"type":{"description":"The parameter type, as plain text.","type":"string"}},"required":["name"],"type":"object"},"minItems":1,"type":"array"},"type":{"const":"split_doc_param_list"},"uid":{"description":"Leave unset; generated server-side.","type":"string"}},"required":["type","header","params"],"title":"split_doc_param_list","type":"object"}],"description":"A canonical block. Its type decides which fields it takes."},"block_uid":{"description":"The uid of the block being replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","block"],"type":"object"},"name":"replace_block"},"reset_hook":{"desc":"Reset a hook's score to 100 and its state to what a fresh hook has, keeping its settings, and return it as list_hooks would. Use it once the document has been brought up to date with whatever the hook flagged.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"}},"required":["document_id","hook_id"],"type":"object"},"name":"reset_hook"},"search_documents":{"desc":"Full-text search across every branch of every document in the organisation for blocks whose text matches the query. Returns hits with document_id, document_name, branch_id, branch_name, default (whether that branch is the document's default), block_uid and text; branch_id is what a follow-up get_document or read_block takes. A block a fork copied unchanged is found on every branch that holds it. Use it to find where a topic is discussed or which documents the user might mean; use list_documents when you know the title. A hit names the innermost block holding the text, which may sit below anything get_document lists; read_block resolves it.","has_params_one_of":true,"json_schema":{"properties":{"limit":{"description":"Maximum number of hits to return. Defaults to 20; cap is 50.","type":"integer"},"query":{"description":"The full-text search query (typo-tolerant, matches block text).","type":"string"}},"required":["query"],"type":"object"},"name":"search_documents"},"unassign_tag":{"desc":"Take a tag off one branch of a document; the tag itself stays for other documents, so use delete_tag to remove it everywhere. document_id and branch_id name the branch the way the content tools do, and tag_id is the tag's id from the document's tags on get_document or from list_tags. A tag the branch does not carry is nothing to do, and the call still succeeds. Returns {document_id, branch_id, tag_id}.","has_params_one_of":true,"json_schema":{"properties":{"branch_id":{"description":"The id of the branch the tag goes on or comes off: a document's default_branch_id from list_documents or list_tags, or any id from the branches get_document lists.","type":"string"},"document_id":{"description":"The document id.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["document_id","branch_id","tag_id"],"type":"object"},"name":"unassign_tag"},"update_block_attrs":{"desc":"Set or override named attributes on an existing block, such as a heading's level or a callout's icon. Attributes not mentioned are kept and uid cannot change. Values are validated for the block's type, so a level outside 1 to 3 or a metric width other than compact, standard or wide is rejected. Use replace_block when the type itself has to change. Returns the summary rows of the block and everything nested in it, attrs as they now stand.","has_params_one_of":true,"json_schema":{"properties":{"attrs":{"description":"Attribute keys and values to set (e.g. {\"level\": 2}, {\"icon\": \"lucide:warning\"}).","type":"object"},"block_uid":{"description":"The uid of the block whose attrs should be updated.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"}},"required":["document_id","branch_id","block_uid","attrs"],"type":"object"},"name":"update_block_attrs"},"update_block_text":{"desc":"Replace the inline text of one text-bearing block: paragraph, heading, blockquote, code, titled_code, mermaid, or a callout written with text. Type, attrs and uid are kept, so this is the tool for wording changes. Text follows the canonical markdown subset (**bold**, *italic*, _underline_, ~~strike~~, backtick code, [label](url)), and is raw in code, titled_code and mermaid. One block is one paragraph; to add a paragraph, insert a block instead. Returns the block's summary row with the new text.","has_params_one_of":true,"json_schema":{"properties":{"block_uid":{"description":"The uid of the block whose text should be replaced.","type":"string"},"branch_id":{"description":"The id of the branch to read or write: a document's default_branch_id from list_documents, a search hit's branch_id, or any id from the branches get_document lists. A protected branch can be read but refuses every write.","type":"string"},"document_id":{"description":"The target document id.","type":"string"},"text":{"description":"New inline text in canonical markdown.","type":"string"}},"required":["document_id","branch_id","block_uid","text"],"type":"object"},"name":"update_block_text"},"update_document":{"desc":"Change a document's name, icon or place in the tree, any combination in one call; content is untouched. Give only the fields to change: name is the new display name, icon an Iconify identifier as \"collection:name\" (the product's own icons are MingCute fills, e.g. \"mingcute:rocket-fill\", so prefer one to match the sidebar), and parent_id the new parent, or an empty string for the organisation root. A call with none of the three is refused, and a parent that is the document itself or one of its descendants fails. Returns {document_id} with the fields that changed.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"icon":{"description":"Optional. The new icon identifier; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"parent_id":{"description":"Optional. The new parent document id, or an empty string to move the document to the organisation root; omit to leave it where it is.","type":"string"}},"required":["document_id"],"type":"object"},"name":"update_document"},"update_hook":{"desc":"Replace the settings of a hook and reset its score and state, returning it as list_hooks would. settings.type is one of scheduled-reminder, github-tracking, url-watcher and container-image-watcher, and the rest of settings is that type's own fields. github-tracking and url-watcher depend on the deployment's GitHub App and changedetection.io integrations, and are refused where those are missing. The hook keeps its type, so settings.type is the type list_hooks reports for it; to change the type, delete the hook and create another.","has_params_one_of":true,"json_schema":{"properties":{"document_id":{"description":"The document id.","type":"string"},"hook_id":{"description":"The hook id, as list_hooks reports it.","type":"string"},"settings":{"anyOf":[{"additionalProperties":false,"properties":{"schedule":{"description":"The RFC 3339 timestamp at which the reminder is due.","type":"string"},"type":{"const":"scheduled-reminder"}},"required":["type","schedule"],"title":"scheduled-reminder","type":"object"},{"additionalProperties":false,"properties":{"branch":{"description":"The repository branch to follow.","type":"string"},"paths":{"description":"The repository paths whose changes mark the document stale.","items":{"type":"string"},"minItems":1,"type":"array"},"repository":{"description":"The repository to follow, as owner/name.","type":"string"},"type":{"const":"github-tracking"}},"required":["type","repository","branch","paths"],"title":"github-tracking","type":"object"},{"additionalProperties":false,"properties":{"type":{"const":"url-watcher"},"url":{"description":"The web address to watch for changes.","type":"string"}},"required":["type","url"],"title":"url-watcher","type":"object"},{"additionalProperties":false,"properties":{"image":{"description":"The container image reference to watch for new digests.","type":"string"},"type":{"const":"container-image-watcher"}},"required":["type","image"],"title":"container-image-watcher","type":"object"}],"description":"The hook's type and its settings."}},"required":["document_id","hook_id","settings"],"type":"object"},"name":"update_hook"},"update_tag":{"desc":"Rename and/or recolour a tag; give only the fields to change, and the other keeps its value. name is the new display name, which has to be unused in the organisation, and color a hex triplet with the leading hash; a call with neither is refused. Returns {tag_id} with the fields that changed. To change which documents carry the tag use assign_tag and unassign_tag instead.","has_params_one_of":true,"json_schema":{"properties":{"color":{"description":"Optional. The new colour as a hex triplet with the leading hash; omit to keep the current one.","type":"string"},"name":{"description":"Optional. The new display name; omit to keep the current one.","type":"string"},"tag_id":{"description":"The tag id, as list_tags or a document's tags report it.","type":"string"}},"required":["tag_id"],"type":"object"},"name":"update_tag"}} \ No newline at end of file diff --git a/server/core/internal/assistant/tools/tools.go b/server/core/internal/assistant/tools/tools.go index 88f7b23f..60632f78 100644 --- a/server/core/internal/assistant/tools/tools.go +++ b/server/core/internal/assistant/tools/tools.go @@ -100,8 +100,6 @@ type Set struct { // New creates a fresh instance of Set. func New(deps *Deps) *Set { - registerConfirmTypes() - all := []Tool{ listDocuments{}, getDocument{}, diff --git a/server/core/internal/assistant/tools/util.go b/server/core/internal/assistant/tools/util.go index 3352e479..790f50c1 100644 --- a/server/core/internal/assistant/tools/util.go +++ b/server/core/internal/assistant/tools/util.go @@ -3,12 +3,6 @@ package tools import ( "encoding/json" "fmt" - "strings" - - "github.com/oxynote/oxynote/server/core/internal/assistant/block" - "github.com/oxynote/oxynote/server/core/internal/assistant/edit" - "github.com/oxynote/oxynote/server/core/internal/document" - "github.com/oxynote/oxynote/server/core/pkg/strutil" ) // result is the single place tool result envelopes are serialised; @@ -22,113 +16,7 @@ func result(v any) (string, error) { return string(data), nil } -// joinOpErrors renders per-operation failures as one message. The -// index is left out: a tool sends a single operation, so naming its -// position says nothing the reader can act on. -func joinOpErrors(errs []edit.OpError) string { - msgs := make([]string, 0, len(errs)) - - for _, e := range errs { - msgs = append(msgs, describeOpError(e.Message)) - } - - return strings.Join(msgs, "; ") -} - -// _opKindNames maps the realtime service's operation kinds, which it -// names in its errors, to the tools the model knows them as. -var _opKindNames = strings.NewReplacer("update_text", "update_block_text") - -// describeOpError rewrites a realtime-service operation error into -// what the model can act on: a uid it holds no block for points at -// get_document, a reference inside the moved block says what to pick -// instead, and an operation kind is named as its tool. A message with -// no rewrite passes through as it is. -func describeOpError(msg string) string { - for _, prefix := range []string{"reference_uid not found: ", "block_uid not found: "} { - if uid, ok := strings.CutPrefix(msg, prefix); ok { - return fmt.Sprintf("no block with uid %s in this document; call get_document for the current uids", uid) - } - } - - if uid, ok := strings.CutPrefix(msg, "reference_uid is inside the moved block: "); ok { - return fmt.Sprintf("reference block %s sits inside the block being moved; choose a reference outside it", uid) - } - - return _opKindNames.Replace(msg) -} - // errRequired reports an argument the tool cannot act without. func errRequired(key string) error { return fmt.Errorf("%s is required", key) } - -// blockKindLabel turns a canonical block type into a friendlier label -// for the confirm UI. Unknown types fall through verbatim. -func blockKindLabel(kind block.Type) string { - switch kind { - case block.BlockParagraph: - return "a paragraph" - case block.BlockHeading: - return "a heading" - case block.BlockBlockquote: - return "a blockquote" - case block.BlockBulletList: - return "a bullet list" - case block.BlockOrderedList: - return "an ordered list" - case block.BlockTaskList: - return "a task list" - case block.BlockCallout: - return "a callout" - case block.BlockCode: - return "a code block" - case block.BlockTitledCode: - return "a titled code block" - case block.BlockMermaid: - return "a mermaid diagram" - case block.BlockHorizontalRule: - return "a divider" - case block.BlockImage: - return "an image" - case block.BlockFile: - return "a file attachment" - case block.BlockFigma: - return "a figma embed" - case block.BlockMetric: - return "a metric" - case block.BlockMetricGrid: - return "a metric grid" - case block.BlockSplitDoc: - return "a split documentation block" - case block.BlockParamList: - return "a parameter list" - } - - if kind == "" { - return "a block" - } - - return "a " + string(kind) + " block" -} - -// textPreview returns at most maxLen runes of s, collapsed to a single -// line. Used for surfacing short snippets of an upcoming text edit in -// the confirm UI. -func textPreview(s string, maxLen int) string { - s = strings.ReplaceAll(s, "\n", " ") - s = strings.TrimSpace(s) - - return strutil.Ellipsize(s, maxLen) -} - -// docLabel names a document the way a status line or a confirm card -// shows it: by display name, with the branch appended when it is not the -// document's default one, so a write aimed at a branch reads as such. -func docLabel(doc *document.Document) string { - if doc.Default { - return doc.DocumentName - } - - return doc.DocumentName + " on branch " + doc.BranchName -} diff --git a/server/core/internal/assistant/tools/util_test.go b/server/core/internal/assistant/tools/util_test.go index 2dfaddd4..cdbde204 100644 --- a/server/core/internal/assistant/tools/util_test.go +++ b/server/core/internal/assistant/tools/util_test.go @@ -3,8 +3,6 @@ package tools import ( "testing" - "github.com/oxynote/oxynote/server/core/internal/assistant/block" - "github.com/oxynote/oxynote/server/core/internal/assistant/edit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -27,111 +25,3 @@ func Test_errRequired(t *testing.T) { assert.EqualError(t, errRequired("document_id"), "document_id is required") } - -func Test_blockKindLabel(t *testing.T) { - cc := map[string]struct { - Kind block.Type - Result string - }{ - "Paragraph": {Kind: "paragraph", Result: "a paragraph"}, - "Heading": {Kind: "heading", Result: "a heading"}, - "Blockquote": {Kind: "blockquote", Result: "a blockquote"}, - "Bullet list": {Kind: "bullet_list", Result: "a bullet list"}, - "Ordered list": {Kind: "ordered_list", Result: "an ordered list"}, - "Task list": {Kind: "task_list", Result: "a task list"}, - "Callout": {Kind: "callout", Result: "a callout"}, - "Code": {Kind: "code", Result: "a code block"}, - "Titled code": {Kind: "titled_code", Result: "a titled code block"}, - "Mermaid": {Kind: "mermaid", Result: "a mermaid diagram"}, - "Horizontal rule": {Kind: "horizontal_rule", Result: "a divider"}, - "Image": {Kind: "image", Result: "an image"}, - "File": {Kind: "file", Result: "a file attachment"}, - "Figma": {Kind: "figma", Result: "a figma embed"}, - "Metric": {Kind: "metric", Result: "a metric"}, - "Metric grid": {Kind: "metric_grid", Result: "a metric grid"}, - "Split doc": {Kind: "split_doc", Result: "a split documentation block"}, - "Param list": {Kind: "split_doc_param_list", Result: "a parameter list"}, - "Empty": {Kind: "", Result: "a block"}, - "Unknown": {Kind: "wibble", Result: "a wibble block"}, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, blockKindLabel(c.Kind)) - }) - } -} - -func Test_textPreview(t *testing.T) { - cc := map[string]struct { - Input string - MaxLen int - Result string - }{ - "Newlines collapse": {Input: "a\nb\nc", MaxLen: 10, Result: "a b c"}, - "Whitespace trimmed": {Input: " x ", MaxLen: 10, Result: "x"}, - "Long text elided": {Input: "abcdef", MaxLen: 3, Result: "abc…"}, - "Empty input": {Input: "", MaxLen: 3, Result: ""}, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, textPreview(c.Input, c.MaxLen)) - }) - } -} - -func Test_joinOpErrors(t *testing.T) { - t.Parallel() - - got := joinOpErrors([]edit.OpError{ - {Index: 0, Message: "block_uid not found: a"}, - {Index: 1, Message: "something else"}, - }) - - // each message is rewritten for the model and the index is left out, - // since a tool ships one operation. - assert.Equal(t, "no block with uid a in this document; call get_document for the current uids; something else", got) -} - -func Test_describeOpError(t *testing.T) { - t.Parallel() - - cc := map[string]struct { - Msg string - Result string - }{ - "Reference uid not found": { - Msg: "reference_uid not found: r1", - Result: "no block with uid r1 in this document; call get_document for the current uids", - }, - "Block uid not found": { - Msg: "block_uid not found: b1", - Result: "no block with uid b1 in this document; call get_document for the current uids", - }, - "Reference inside the moved block": { - Msg: "reference_uid is inside the moved block: r1", - Result: "reference block r1 sits inside the block being moved; choose a reference outside it", - }, - "Operation kind named as its tool": { - Msg: "update_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_text on the block holding the text.", - Result: "update_block_text does not apply to calloutBlock: use replace_block to rewrite it whole, or update_block_text on the block holding the text.", - }, - "Unknown message passes through": { - Msg: "cannot move a block relative to itself: a", - Result: "cannot move a block relative to itself: a", - }, - } - - for cn, c := range cc { - t.Run(cn, func(t *testing.T) { - t.Parallel() - - assert.Equal(t, c.Result, describeOpError(c.Msg)) - }) - } -} diff --git a/server/core/internal/document/document.go b/server/core/internal/document/document.go index ee44abbc..83ce8044 100644 --- a/server/core/internal/document/document.go +++ b/server/core/internal/document/document.go @@ -266,6 +266,17 @@ func (d Document) ApplyBranchUpdate(name null.String, protected null.Bool, updat return nd } +// Title names the document the way a status line or a confirm card +// shows it: by display name, with the branch appended when it is not the +// default one, so a write aimed at a branch reads as such. +func (d Document) Title() string { + if d.Default { + return d.DocumentName + } + + return d.DocumentName + " on branch " + d.BranchName +} + // HistoryEntry returns a history entry for the current state of the document. // Entries aggregate per branch and per time bucket: the branch is part of the // ID so that two branches of one document edited within the same bucket keep diff --git a/server/core/internal/tag/tag.go b/server/core/internal/tag/tag.go index 401a84da..76f73b31 100644 --- a/server/core/internal/tag/tag.go +++ b/server/core/internal/tag/tag.go @@ -109,6 +109,12 @@ func NewTag(inp CreateInput, organizationID, userID string) Tag { } } +// Summary returns the tag on its own: the identity, name and colour, +// without the per-user hidden flag or the documents carrying it. +func (t Tag) Summary() Summary { + return Summary{ID: t.ID, TagName: t.TagName, Color: t.Color} +} + // UpdateInput is the input for renaming or recolouring a tag. A field left // unset keeps its current value. type UpdateInput struct { diff --git a/server/core/internal/tag/tag_test.go b/server/core/internal/tag/tag_test.go index f7c15f19..7186a489 100644 --- a/server/core/internal/tag/tag_test.go +++ b/server/core/internal/tag/tag_test.go @@ -108,6 +108,15 @@ func Test_NewTag(t *testing.T) { assert.Zero(t, tg.SortIndex) } +func Test_Tag_Summary(t *testing.T) { + t.Parallel() + + id := xid.New() + tg := Tag{ID: id, OrganizationID: "org1", TagName: "Production", Color: "#22c55e", SortIndex: 3} + + assert.Equal(t, Summary{ID: id, TagName: "Production", Color: "#22c55e"}, tg.Summary()) +} + func Test_Summaries_Swap(t *testing.T) { t.Parallel() diff --git a/server/core/pkg/strutil/strutil.go b/server/core/pkg/strutil/strutil.go index d17ee2c3..c4897a75 100644 --- a/server/core/pkg/strutil/strutil.go +++ b/server/core/pkg/strutil/strutil.go @@ -2,6 +2,8 @@ // codebase. package strutil +import "strings" + // Ellipsize returns at most maxLen runes of s, appending a single // ellipsis rune when the input was longer. A non-positive maxLen // returns s unchanged. @@ -17,3 +19,12 @@ func Ellipsize(s string, maxLen int) string { return string(runes[:maxLen]) + "…" } + +// Preview returns at most maxLen runes of s, collapsed to a single line +// and trimmed, elided the way Ellipsize does. +func Preview(s string, maxLen int) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.TrimSpace(s) + + return Ellipsize(s, maxLen) +} diff --git a/server/core/pkg/strutil/strutil_test.go b/server/core/pkg/strutil/strutil_test.go index 44d497d5..9aec7605 100644 --- a/server/core/pkg/strutil/strutil_test.go +++ b/server/core/pkg/strutil/strutil_test.go @@ -34,3 +34,24 @@ func Test_Ellipsize(t *testing.T) { }) } } + +func Test_Preview(t *testing.T) { + cc := map[string]struct { + Input string + MaxLen int + Result string + }{ + "Newlines collapse": {Input: "a\nb\nc", MaxLen: 10, Result: "a b c"}, + "Whitespace trimmed": {Input: " x ", MaxLen: 10, Result: "x"}, + "Long text elided": {Input: "abcdef", MaxLen: 3, Result: "abc…"}, + "Empty input": {Input: "", MaxLen: 3, Result: ""}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, Preview(c.Input, c.MaxLen)) + }) + } +}