diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 6a7f1692fe..4ea3a17a6c 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@superdoc/document-api": "workspace:*", + "@superdoc/llm-tools": "workspace:*", "@superdoc/super-editor": "workspace:*", "superdoc": "workspace:*", "@types/bun": "catalog:", diff --git a/apps/mcp/src/__tests__/intent-tools.test.ts b/apps/mcp/src/__tests__/intent-tools.test.ts new file mode 100644 index 0000000000..7b49c679df --- /dev/null +++ b/apps/mcp/src/__tests__/intent-tools.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'bun:test'; +import { dispatch, ALL_TOOLS } from '@superdoc/llm-tools'; + +/** Mock executor that records calls and returns a success object. */ +function mockExecutor() { + const calls: Array<{ operationId: string; input: Record; options?: Record }> = []; + + const execute = async (operationId: string, input: Record, options?: Record) => { + calls.push({ operationId, input, options }); + return { success: true }; + }; + + return { execute, calls }; +} + +describe('MCP dispatch integration', () => { + it('dispatches superdoc_read with format "text" to getText', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_read', { format: 'text' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('getText'); + }); + + it('dispatches superdoc_read with format "info" to info', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_read', { format: 'info' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('info'); + }); + + it('dispatches superdoc_find with pattern to find', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_find', { pattern: 'hello' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('find'); + expect(calls[0].input).toEqual({ select: { type: 'text', pattern: 'hello', mode: 'contains' } }); + }); + + it('dispatches superdoc_edit insert action to insert', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_edit', { action: 'insert', target: '{"kind":"text"}', text: 'hi' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('insert'); + }); + + it('dispatches superdoc_create paragraph to create.paragraph', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'paragraph', text: 'Hello' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('create.paragraph'); + expect(calls[0].input).toEqual({ text: 'Hello' }); + }); + + it('dispatches superdoc_comment list action to comments.list', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_comment', { action: 'list' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('comments.list'); + }); + + it('dispatches superdoc_review list action to trackChanges.list', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_review', { action: 'list' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('trackChanges.list'); + }); + + it('dispatches superdoc_format with bold to format.apply', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target: '{"kind":"text"}', bold: true }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('format.apply'); + }); + + it('throws for unknown tool name', async () => { + const { execute } = mockExecutor(); + expect(dispatch('superdoc_nonexistent', {}, execute)).rejects.toThrow('Unknown tool'); + }); + + it('all 13 tool names are dispatchable', () => { + const toolNames = ALL_TOOLS.map((t) => t.name); + expect(toolNames).toHaveLength(13); + expect(toolNames).toContain('superdoc_read'); + expect(toolNames).toContain('superdoc_find'); + expect(toolNames).toContain('superdoc_edit'); + expect(toolNames).toContain('superdoc_create'); + expect(toolNames).toContain('superdoc_format'); + expect(toolNames).toContain('superdoc_table'); + expect(toolNames).toContain('superdoc_list'); + expect(toolNames).toContain('superdoc_image'); + expect(toolNames).toContain('superdoc_comment'); + expect(toolNames).toContain('superdoc_review'); + expect(toolNames).toContain('superdoc_section'); + expect(toolNames).toContain('superdoc_reference'); + expect(toolNames).toContain('superdoc_control'); + }); +}); diff --git a/apps/mcp/src/__tests__/json-schema-to-zod.test.ts b/apps/mcp/src/__tests__/json-schema-to-zod.test.ts new file mode 100644 index 0000000000..4b88be138b --- /dev/null +++ b/apps/mcp/src/__tests__/json-schema-to-zod.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'bun:test'; +import { z } from 'zod'; +import { jsonSchemaToZodShape } from '../tools/json-schema-to-zod.js'; +import { ALL_TOOLS } from '@superdoc/llm-tools'; + +describe('jsonSchemaToZodShape', () => { + it('converts string properties', () => { + const shape = jsonSchemaToZodShape({ + properties: { + name: { type: 'string', description: 'A name' }, + }, + required: ['name'], + }); + + expect(shape.name).toBeDefined(); + const result = z.object(shape).safeParse({ name: 'hello' }); + expect(result.success).toBe(true); + }); + + it('converts string enum properties', () => { + const shape = jsonSchemaToZodShape({ + properties: { + action: { type: 'string', enum: ['insert', 'replace', 'delete'], description: 'Action' }, + }, + required: ['action'], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ action: 'insert' }).success).toBe(true); + expect(schema.safeParse({ action: 'invalid' }).success).toBe(false); + }); + + it('converts number properties', () => { + const shape = jsonSchemaToZodShape({ + properties: { + count: { type: 'number', description: 'A count' }, + }, + required: ['count'], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ count: 42 }).success).toBe(true); + expect(schema.safeParse({ count: 'nope' }).success).toBe(false); + }); + + it('converts boolean properties', () => { + const shape = jsonSchemaToZodShape({ + properties: { + suggest: { type: 'boolean', description: 'Suggest mode' }, + }, + required: [], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ suggest: true }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(true); // optional + }); + + it('marks required vs optional correctly', () => { + const shape = jsonSchemaToZodShape({ + properties: { + session_id: { type: 'string' }, + target: { type: 'string' }, + text: { type: 'string' }, + }, + required: ['session_id', 'target'], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ session_id: 's1', target: 't1' }).success).toBe(true); + expect(schema.safeParse({ session_id: 's1' }).success).toBe(false); // missing target + }); + + it('converts nested object properties', () => { + const shape = jsonSchemaToZodShape({ + properties: { + start: { + type: 'object', + properties: { rowIndex: { type: 'number' }, columnIndex: { type: 'number' } }, + description: 'Start cell', + }, + }, + required: [], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ start: { rowIndex: 0, columnIndex: 1 } }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(true); // optional + }); + + it('converts array properties', () => { + const shape = jsonSchemaToZodShape({ + properties: { + keys: { + type: 'array', + items: { + type: 'object', + properties: { + columnIndex: { type: 'number' }, + direction: { type: 'string', enum: ['ascending', 'descending'] }, + }, + }, + description: 'Sort keys', + }, + }, + required: [], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ keys: [{ columnIndex: 0, direction: 'ascending' }] }).success).toBe(true); + }); + + it('handles properties with no type (z.any)', () => { + const shape = jsonSchemaToZodShape({ + properties: { + value: { description: 'Any value' }, + }, + required: [], + }); + + const schema = z.object(shape); + expect(schema.safeParse({ value: 'text' }).success).toBe(true); + expect(schema.safeParse({ value: 42 }).success).toBe(true); + expect(schema.safeParse({ value: true }).success).toBe(true); + }); + + it('converts all 13 llm-tools definitions without error', () => { + expect(ALL_TOOLS).toHaveLength(13); + + for (const tool of ALL_TOOLS) { + const shape = jsonSchemaToZodShape(tool.inputSchema); + expect(shape).toBeDefined(); + expect(shape.session_id).toBeDefined(); + + // Verify the shape can be used in z.object + const schema = z.object(shape); + expect(schema).toBeDefined(); + } + }); +}); diff --git a/apps/mcp/src/__tests__/protocol.test.ts b/apps/mcp/src/__tests__/protocol.test.ts index a70864982b..100f52dc58 100644 --- a/apps/mcp/src/__tests__/protocol.test.ts +++ b/apps/mcp/src/__tests__/protocol.test.ts @@ -7,29 +7,24 @@ const BLANK_DOCX = resolve(import.meta.dir, '../../../../shared/common/data/blan const SERVER_ENTRY = resolve(import.meta.dir, '../index.ts'); const EXPECTED_TOOLS = [ + // Lifecycle tools (transport-specific) 'superdoc_open', 'superdoc_save', 'superdoc_close', + // Intent-based tools (from @superdoc/llm-tools) + 'superdoc_read', 'superdoc_find', - 'superdoc_get_node', - 'superdoc_info', - 'superdoc_get_text', - 'superdoc_insert', - 'superdoc_replace', - 'superdoc_delete', - 'superdoc_format', + 'superdoc_edit', 'superdoc_create', - 'superdoc_list_changes', - 'superdoc_accept_change', - 'superdoc_reject_change', - 'superdoc_accept_all_changes', - 'superdoc_reject_all_changes', - 'superdoc_add_comment', - 'superdoc_list_comments', - 'superdoc_reply_comment', - 'superdoc_resolve_comment', - 'superdoc_insert_list', - 'superdoc_list_create', + 'superdoc_format', + 'superdoc_table', + 'superdoc_list', + 'superdoc_image', + 'superdoc_comment', + 'superdoc_review', + 'superdoc_section', + 'superdoc_reference', + 'superdoc_control', ]; function textContent(result: Awaited>): string { @@ -79,7 +74,7 @@ describe('MCP protocol integration', () => { } }); - it('open → info → get_text → close workflow', async () => { + it('open → read info → read text → close workflow', async () => { await ready; // Open @@ -90,12 +85,18 @@ describe('MCP protocol integration', () => { const sid = opened.session_id; - // Info - const infoResult = await client.callTool({ name: 'superdoc_info', arguments: { session_id: sid } }); + // Read info + const infoResult = await client.callTool({ + name: 'superdoc_read', + arguments: { session_id: sid, format: 'info' }, + }); expect(textContent(infoResult)).toBeTruthy(); - // Get text - const textResult = await client.callTool({ name: 'superdoc_get_text', arguments: { session_id: sid } }); + // Read text + const textResult = await client.callTool({ + name: 'superdoc_read', + arguments: { session_id: sid, format: 'text' }, + }); expect(textContent(textResult)).toBeDefined(); // Close diff --git a/apps/mcp/src/__tests__/tools.test.ts b/apps/mcp/src/__tests__/tools.test.ts index ce36edc591..7d9d80094a 100644 --- a/apps/mcp/src/__tests__/tools.test.ts +++ b/apps/mcp/src/__tests__/tools.test.ts @@ -39,7 +39,7 @@ describe('MCP tools integration', () => { const result = api.invoke({ operationId: 'find', input: { - query: { select: { type: 'node', nodeType: 'paragraph' } }, + select: { type: 'node', nodeKind: 'paragraph' }, }, }); diff --git a/apps/mcp/src/tools/comments.ts b/apps/mcp/src/tools/comments.ts deleted file mode 100644 index b0f038324f..0000000000 --- a/apps/mcp/src/tools/comments.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -export function registerCommentTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_add_comment', - { - title: 'Add Comment', - description: - 'Add a comment anchored to a text range in the document. Use superdoc_find with a text pattern first, then pass a TextAddress from items[].context.textRanges as the target.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - text: z.string().describe('The comment text (question, concern, or feedback).'), - target: z - .string() - .describe( - 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find items[].context.textRanges.', - ), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, text, target }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const result = api.invoke({ - operationId: 'comments.create', - input: { text, target: parsed }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Add comment failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_list_comments', - { - title: 'List Comments', - description: - 'List all comments in the document. Returns comment text, author, status (open/resolved), and the text range each comment is anchored to.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - include_resolved: z.boolean().optional().describe('Include resolved comments. Defaults to true.'), - }, - annotations: { readOnlyHint: true }, - }, - async ({ session_id, include_resolved }) => { - try { - const { api } = sessions.get(session_id); - const input: Record = {}; - if (include_resolved != null) input.includeResolved = include_resolved; - - const result = api.invoke({ operationId: 'comments.list', input }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `List comments failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_reply_comment', - { - title: 'Reply to Comment', - description: 'Reply to an existing comment thread. Use the comment ID from superdoc_list_comments.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - comment_id: z.string().describe('The parent comment ID to reply to (from superdoc_list_comments).'), - text: z.string().describe('The reply text.'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, comment_id, text }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ - operationId: 'comments.create', - input: { parentCommentId: comment_id, text }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Reply failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_resolve_comment', - { - title: 'Resolve Comment', - description: 'Mark a comment as resolved. Use the comment ID from superdoc_list_comments.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - comment_id: z.string().describe('The comment ID to resolve (from superdoc_list_comments).'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, comment_id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ - operationId: 'comments.patch', - input: { commentId: comment_id, status: 'resolved' }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Resolve failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); -} diff --git a/apps/mcp/src/tools/create.ts b/apps/mcp/src/tools/create.ts deleted file mode 100644 index f623d3f901..0000000000 --- a/apps/mcp/src/tools/create.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -const TYPES = ['paragraph', 'heading'] as const; - -export function registerCreateTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_create', - { - title: 'Create Block', - description: - 'Create a new block element in the document. Supports paragraphs and headings. Optionally specify text content and position. Set suggest=true to create as a tracked change (suggestion).', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - type: z.enum(TYPES).describe('The type of block to create.'), - text: z.string().optional().describe('Text content for the new block.'), - level: z.number().min(1).max(6).optional().describe('Heading level (1-6). Required when type is "heading".'), - at: z - .string() - .optional() - .describe('JSON-encoded position specifying where to create the block. If omitted, appends to the end.'), - suggest: z - .boolean() - .optional() - .describe( - 'If true, create as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', - ), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, type, text, level, at, suggest }) => { - try { - const { api } = sessions.get(session_id); - const input: Record = {}; - if (text != null) input.text = text; - if (level != null) input.level = level; - if (at != null) input.at = JSON.parse(at); - - const result = api.invoke({ - operationId: `create.${type}`, - input, - options: suggest ? { changeMode: 'tracked' as const } : undefined, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Create failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); -} diff --git a/apps/mcp/src/tools/format.ts b/apps/mcp/src/tools/format.ts deleted file mode 100644 index 1acab87ce9..0000000000 --- a/apps/mcp/src/tools/format.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -const STYLES = ['bold', 'italic', 'underline', 'strikethrough'] as const; -const INLINE_BY_STYLE = { - bold: { bold: 'on' }, - italic: { italic: 'on' }, - underline: { underline: 'on' }, - strikethrough: { strike: 'on' }, -} as const; - -export function registerFormatTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_format', - { - title: 'Format Text', - description: - 'Apply formatting on a text range. Use superdoc_find with a text pattern first, then pass a TextAddress from items[].context.textRanges as the target. Set suggest=true to format as a tracked change (suggestion).', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - style: z.enum(STYLES).describe('The formatting style to apply.'), - target: z - .string() - .describe( - 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find items[].context.textRanges.', - ), - suggest: z - .boolean() - .optional() - .describe( - 'If true, format as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', - ), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, style, target, suggest }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const result = api.invoke({ - operationId: 'format.apply', - input: { target: parsed, inline: INLINE_BY_STYLE[style] }, - options: suggest ? { changeMode: 'tracked' as const } : undefined, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Format failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); -} diff --git a/apps/mcp/src/tools/index.ts b/apps/mcp/src/tools/index.ts index 7c17736789..5b45cb4c25 100644 --- a/apps/mcp/src/tools/index.ts +++ b/apps/mcp/src/tools/index.ts @@ -1,21 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { SessionManager } from '../session-manager.js'; import { registerLifecycleTools } from './lifecycle.js'; -import { registerQueryTools } from './query.js'; -import { registerMutationTools } from './mutation.js'; -import { registerFormatTools } from './format.js'; -import { registerCreateTools } from './create.js'; -import { registerTrackChangesTools } from './track-changes.js'; -import { registerCommentTools } from './comments.js'; -import { registerListTools } from './lists.js'; +import { registerIntentTools } from './intent-tools.js'; export function registerAllTools(server: McpServer, sessions: SessionManager): void { registerLifecycleTools(server, sessions); - registerQueryTools(server, sessions); - registerMutationTools(server, sessions); - registerFormatTools(server, sessions); - registerCreateTools(server, sessions); - registerTrackChangesTools(server, sessions); - registerCommentTools(server, sessions); - registerListTools(server, sessions); + registerIntentTools(server, sessions); } diff --git a/apps/mcp/src/tools/intent-tools.ts b/apps/mcp/src/tools/intent-tools.ts new file mode 100644 index 0000000000..5cd7e72a77 --- /dev/null +++ b/apps/mcp/src/tools/intent-tools.ts @@ -0,0 +1,55 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ALL_TOOLS, dispatch } from '@superdoc/llm-tools'; +import type { DynamicInvokeRequest } from '@superdoc/document-api'; +import type { SessionManager } from '../session-manager.js'; +import { jsonSchemaToZodShape } from './json-schema-to-zod.js'; + +/** Derive a human-readable title from a tool name like "superdoc_edit" → "Edit". */ +function titleFromName(name: string): string { + return name + .replace(/^superdoc_/, '') + .split('_') + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(' '); +} + +/** + * Register all 13 intent-based tools from @superdoc/llm-tools. + * Each tool delegates to the llm-tools routing layer via `dispatch()`. + */ +export function registerIntentTools(server: McpServer, sessions: SessionManager): void { + for (const tool of ALL_TOOLS) { + const zodShape = jsonSchemaToZodShape(tool.inputSchema); + + server.registerTool( + tool.name, + { + title: titleFromName(tool.name), + description: tool.description, + inputSchema: zodShape, + annotations: tool.annotations, + }, + async (params: Record) => { + try { + const sessionId = params.session_id as string; + const { api } = sessions.get(sessionId); + + // Build the transport-agnostic executor that llm-tools expects. + const execute = (operationId: string, input: Record, options?: Record) => + Promise.resolve(api.invoke({ operationId, input, options } as DynamicInvokeRequest)); + + const result = await dispatch(tool.name, params, execute); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `${titleFromName(tool.name)} failed: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + } +} diff --git a/apps/mcp/src/tools/json-schema-to-zod.ts b/apps/mcp/src/tools/json-schema-to-zod.ts new file mode 100644 index 0000000000..4ddb1c0123 --- /dev/null +++ b/apps/mcp/src/tools/json-schema-to-zod.ts @@ -0,0 +1,77 @@ +import { z, type ZodTypeAny } from 'zod'; + +type JsonSchemaProp = Record; + +/** + * Convert a single JSON Schema property descriptor to a Zod type. + * Handles the subset used by @superdoc/llm-tools definitions: + * string (with optional enum), number, boolean, object, array, and any. + */ +function propertyToZod(prop: JsonSchemaProp): ZodTypeAny { + const desc = typeof prop.description === 'string' ? prop.description : undefined; + + let schema: ZodTypeAny; + + switch (prop.type) { + case 'string': + if (Array.isArray(prop.enum) && prop.enum.length > 0) { + schema = z.enum(prop.enum as [string, ...string[]]); + } else { + schema = z.string(); + } + break; + + case 'number': + schema = z.number(); + break; + + case 'boolean': + schema = z.boolean(); + break; + + case 'object': { + if (prop.properties && typeof prop.properties === 'object') { + const shape: Record = {}; + for (const [k, v] of Object.entries(prop.properties as Record)) { + shape[k] = propertyToZod(v).optional(); + } + schema = z.object(shape); + } else { + schema = z.record(z.string(), z.unknown()); + } + break; + } + + case 'array': { + const items = prop.items as JsonSchemaProp | undefined; + schema = z.array(items ? propertyToZod(items) : z.unknown()); + break; + } + + default: + schema = z.any(); + break; + } + + return desc ? schema.describe(desc) : schema; +} + +/** + * Convert a JSON Schema `{ type: 'object', properties, required }` into a + * Zod raw shape (`Record`) suitable for `registerTool`. + * + * Properties NOT listed in `required` are wrapped with `.optional()`. + */ +export function jsonSchemaToZodShape(schema: Record): Record { + const properties = (schema.properties ?? {}) as Record; + const required = new Set(Array.isArray(schema.required) ? (schema.required as string[]) : []); + + const shape: Record = {}; + + for (const [key, prop] of Object.entries(properties)) { + const zodType = propertyToZod(prop); + shape[key] = required.has(key) ? zodType : zodType.optional(); + } + + return shape; +} diff --git a/apps/mcp/src/tools/lists.ts b/apps/mcp/src/tools/lists.ts deleted file mode 100644 index 1f5e2aefa1..0000000000 --- a/apps/mcp/src/tools/lists.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -export function registerListTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_insert_list', - { - title: 'Insert List Item', - description: - 'Insert a new list item before or after an existing one. To start a new list, use superdoc_create with type "paragraph" first, then convert it. Or use superdoc_find to locate an existing list item.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - target: z - .string() - .describe('JSON-encoded list item address from superdoc_find or superdoc_list_items results.'), - position: z.enum(['before', 'after']).describe('Insert before or after the target item.'), - text: z.string().optional().describe('Text content for the new list item.'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, target, position, text }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const input: Record = { target: parsed, position }; - if (text != null) input.text = text; - - const result = api.invoke({ - operationId: 'lists.insert', - input, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Insert list item failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_list_create', - { - title: 'Create List', - description: - 'Create a new list from one or more existing paragraphs. Use superdoc_find to locate paragraph addresses first.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - target: z - .string() - .describe( - 'JSON-encoded block address (or range) of the paragraph(s) to convert. Use { "kind": "block", "nodeType": "paragraph", "nodeId": "..." }.', - ), - kind: z.enum(['ordered', 'bullet']).describe('The list type to create.'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, target, kind }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const result = api.invoke({ - operationId: 'lists.create', - input: { mode: 'fromParagraphs', target: parsed, kind }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Create list failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); -} diff --git a/apps/mcp/src/tools/mutation.ts b/apps/mcp/src/tools/mutation.ts deleted file mode 100644 index fbd21bd867..0000000000 --- a/apps/mcp/src/tools/mutation.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -function mutationOptions(suggest?: boolean) { - return suggest ? { changeMode: 'tracked' as const } : undefined; -} - -export function registerMutationTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_insert', - { - title: 'Insert Text', - description: - 'Insert text at a target position in the document. Use superdoc_find first, then pass a TextAddress from items[].context.textRanges as the target. Set suggest=true to insert as a tracked change (suggestion) instead of a direct edit.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - text: z.string().describe('The text content to insert.'), - target: z - .string() - .describe( - 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find items[].context.textRanges.', - ), - suggest: z - .boolean() - .optional() - .describe( - 'If true, insert as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', - ), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, text, target, suggest }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const result = api.invoke({ - operationId: 'insert', - input: { text, target: parsed }, - options: mutationOptions(suggest), - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Insert failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_replace', - { - title: 'Replace Text', - description: - 'Replace content at a target range with new text. Use superdoc_find with a text pattern first, then pass a TextAddress from items[].context.textRanges as the target. Set suggest=true to make the replacement a tracked change (suggestion).', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - text: z.string().describe('The replacement text.'), - target: z - .string() - .describe( - 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find items[].context.textRanges.', - ), - suggest: z - .boolean() - .optional() - .describe( - 'If true, replace as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', - ), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, text, target, suggest }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const result = api.invoke({ - operationId: 'replace', - input: { text, target: parsed }, - options: mutationOptions(suggest), - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Replace failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_delete', - { - title: 'Delete Content', - description: - 'Delete content at a target range. Use superdoc_find with a text pattern first, then pass a TextAddress from items[].context.textRanges as the target. Set suggest=true to delete as a tracked change (suggestion).', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - target: z - .string() - .describe( - 'JSON-encoded TextAddress: {"kind":"text","blockId":"...","range":{"start":N,"end":N}}. Get this from superdoc_find items[].context.textRanges.', - ), - suggest: z - .boolean() - .optional() - .describe( - 'If true, delete as a tracked change (suggestion) that can be accepted or rejected later. Defaults to false (direct edit).', - ), - }, - annotations: { readOnlyHint: false, destructiveHint: true }, - }, - async ({ session_id, target, suggest }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(target); - const result = api.invoke({ - operationId: 'delete', - input: { target: parsed }, - options: mutationOptions(suggest), - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Delete failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); -} diff --git a/apps/mcp/src/tools/query.ts b/apps/mcp/src/tools/query.ts deleted file mode 100644 index 3196197c88..0000000000 --- a/apps/mcp/src/tools/query.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -export function registerQueryTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_find', - { - title: 'Find in Document', - description: - 'Search the document for nodes matching a type, text pattern, or both. For text searches, each item in items[] includes context.textRanges — these are the TextAddress objects you pass as "target" to replace/insert/delete/format tools.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - type: z.string().optional().describe('Node type to filter by (e.g. "heading", "paragraph", "table", "image").'), - pattern: z.string().optional().describe('Text pattern to search for (substring match).'), - limit: z.number().optional().describe('Maximum number of results.'), - offset: z.number().optional().describe('Skip this many results (for pagination).'), - }, - annotations: { readOnlyHint: true }, - }, - async ({ session_id, type, pattern, limit, offset }) => { - try { - const { api } = sessions.get(session_id); - - // Build a Selector or Query object directly — find accepts both. - // Selector: { type: 'text', pattern } or { type: 'node', nodeType } - // Query: { select: Selector, limit?, offset? } - let selector: Record; - if (pattern) { - selector = { type: 'text', pattern, mode: 'contains' }; - } else if (type) { - selector = { type: 'node', nodeType: type }; - } else { - selector = { type: 'node' }; - } - - const input: Record = - limit != null || offset != null ? { select: selector, limit, offset } : selector; - - const result = api.invoke({ operationId: 'find', input }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Find failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_get_node', - { - title: 'Get Node', - description: - 'Get detailed information about a specific document node by its address (from superdoc_find results).', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - address: z.string().describe('JSON-encoded node address from superdoc_find results.'), - }, - annotations: { readOnlyHint: true }, - }, - async ({ session_id, address }) => { - try { - const { api } = sessions.get(session_id); - const parsed = JSON.parse(address); - const result = api.invoke({ operationId: 'getNode', input: parsed }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Get node failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_info', - { - title: 'Document Info', - description: 'Return document metadata: structure summary, node counts, and capabilities.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - }, - annotations: { readOnlyHint: true }, - }, - async ({ session_id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ operationId: 'info', input: {} }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Info failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_get_text', - { - title: 'Get Document Text', - description: 'Return the full plain-text content of the document.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - }, - annotations: { readOnlyHint: true }, - }, - async ({ session_id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ operationId: 'getText', input: {} }); - return { - content: [{ type: 'text' as const, text: typeof result === 'string' ? result : JSON.stringify(result) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Get text failed: ${(err as Error).message}` }], - isError: true, - }; - } - }, - ); -} diff --git a/apps/mcp/src/tools/track-changes.ts b/apps/mcp/src/tools/track-changes.ts deleted file mode 100644 index 41a5b1f84a..0000000000 --- a/apps/mcp/src/tools/track-changes.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { SessionManager } from '../session-manager.js'; - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function registerTrackChangesTools(server: McpServer, sessions: SessionManager): void { - server.registerTool( - 'superdoc_list_changes', - { - title: 'List Tracked Changes', - description: - 'List all tracked changes (suggestions) in the document. Returns change type (insert/delete/format), author, date, and excerpt for each.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - type: z.enum(['insert', 'delete', 'format']).optional().describe('Filter by change type.'), - limit: z.number().optional().describe('Maximum number of results.'), - offset: z.number().optional().describe('Skip this many results (for pagination).'), - }, - annotations: { readOnlyHint: true }, - }, - async ({ session_id, type, limit, offset }) => { - try { - const { api } = sessions.get(session_id); - const input: Record = {}; - if (type != null) input.type = type; - if (limit != null) input.limit = limit; - if (offset != null) input.offset = offset; - - const result = api.invoke({ operationId: 'trackChanges.list', input }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `List changes failed: ${toErrorMessage(err)}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_accept_change', - { - title: 'Accept Tracked Change', - description: - 'Accept a single tracked change (suggestion), applying it to the document. Use the change ID from superdoc_list_changes.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - id: z.string().describe('The tracked change ID from superdoc_list_changes results.'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ - operationId: 'trackChanges.decide', - input: { decision: 'accept', target: { id } }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Accept change failed: ${toErrorMessage(err)}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_reject_change', - { - title: 'Reject Tracked Change', - description: - 'Reject a single tracked change (suggestion), reverting it from the document. Use the change ID from superdoc_list_changes.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - id: z.string().describe('The tracked change ID from superdoc_list_changes results.'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id, id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ - operationId: 'trackChanges.decide', - input: { decision: 'reject', target: { id } }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Reject change failed: ${toErrorMessage(err)}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_accept_all_changes', - { - title: 'Accept All Tracked Changes', - description: 'Accept all tracked changes (suggestions) in the document, applying them all.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - }, - annotations: { readOnlyHint: false }, - }, - async ({ session_id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ - operationId: 'trackChanges.decide', - input: { decision: 'accept', target: { scope: 'all' } }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Accept all failed: ${toErrorMessage(err)}` }], - isError: true, - }; - } - }, - ); - - server.registerTool( - 'superdoc_reject_all_changes', - { - title: 'Reject All Tracked Changes', - description: 'Reject all tracked changes (suggestions) in the document, reverting them all.', - inputSchema: { - session_id: z.string().describe('Session ID from superdoc_open.'), - }, - annotations: { readOnlyHint: false, destructiveHint: true }, - }, - async ({ session_id }) => { - try { - const { api } = sessions.get(session_id); - const result = api.invoke({ - operationId: 'trackChanges.decide', - input: { decision: 'reject', target: { scope: 'all' } }, - }); - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Reject all failed: ${toErrorMessage(err)}` }], - isError: true, - }; - } - }, - ); -} diff --git a/packages/llm-tools/package.json b/packages/llm-tools/package.json new file mode 100644 index 0000000000..309ebbb501 --- /dev/null +++ b/packages/llm-tools/package.json @@ -0,0 +1,14 @@ +{ + "name": "@superdoc/llm-tools", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/llm-tools/src/__tests__/definitions.test.ts b/packages/llm-tools/src/__tests__/definitions.test.ts new file mode 100644 index 0000000000..1e50d00f18 --- /dev/null +++ b/packages/llm-tools/src/__tests__/definitions.test.ts @@ -0,0 +1,76 @@ +import { test, expect, describe } from 'bun:test'; +import { ALL_TOOLS } from '../index.js'; + +describe('tool definitions', () => { + test('exports 13 tools (excluding lifecycle)', () => { + expect(ALL_TOOLS).toHaveLength(13); + }); + + test('all tools have unique names', () => { + const names = ALL_TOOLS.map((t) => t.name); + expect(new Set(names).size).toBe(names.length); + }); + + test('all tool names start with "superdoc_"', () => { + for (const tool of ALL_TOOLS) { + expect(tool.name).toStartWith('superdoc_'); + } + }); + + test('all tools have a description', () => { + for (const tool of ALL_TOOLS) { + expect(tool.description).toBeTruthy(); + expect(tool.description.length).toBeGreaterThan(20); + } + }); + + test('all tools have a valid inputSchema', () => { + for (const tool of ALL_TOOLS) { + expect(tool.inputSchema.type).toBe('object'); + expect(tool.inputSchema.properties).toBeTruthy(); + expect(tool.inputSchema.required).toBeInstanceOf(Array); + } + }); + + test('all tools require session_id', () => { + for (const tool of ALL_TOOLS) { + const required = tool.inputSchema.required as string[]; + expect(required).toContain('session_id'); + } + }); + + test('all tools have annotations', () => { + for (const tool of ALL_TOOLS) { + expect(tool.annotations).toBeTruthy(); + expect(typeof tool.annotations!.readOnlyHint).toBe('boolean'); + } + }); + + test('read-only tools are marked correctly', () => { + const readOnly = ALL_TOOLS.filter((t) => t.annotations?.readOnlyHint); + const readOnlyNames = readOnly.map((t) => t.name); + expect(readOnlyNames).toContain('superdoc_read'); + expect(readOnlyNames).toContain('superdoc_find'); + expect(readOnlyNames).not.toContain('superdoc_edit'); + expect(readOnlyNames).not.toContain('superdoc_format'); + }); + + test('expected tool names are present', () => { + const names = ALL_TOOLS.map((t) => t.name); + expect(names).toEqual([ + 'superdoc_read', + 'superdoc_find', + 'superdoc_edit', + 'superdoc_create', + 'superdoc_format', + 'superdoc_table', + 'superdoc_list', + 'superdoc_image', + 'superdoc_comment', + 'superdoc_review', + 'superdoc_section', + 'superdoc_reference', + 'superdoc_control', + ]); + }); +}); diff --git a/packages/llm-tools/src/__tests__/router.test.ts b/packages/llm-tools/src/__tests__/router.test.ts new file mode 100644 index 0000000000..492c46f395 --- /dev/null +++ b/packages/llm-tools/src/__tests__/router.test.ts @@ -0,0 +1,1100 @@ +import { test, expect, describe } from 'bun:test'; +import { dispatch, ROUTERS, ALL_TOOLS } from '../index.js'; +import type { Executor } from '../types.js'; + +/** Creates a mock executor that records calls and returns a default value. */ +function mockExecutor(returnValue: unknown = { ok: true }) { + const calls: Array<{ operationId: string; input: Record; options?: Record }> = []; + const execute: Executor = async (operationId, input, options) => { + calls.push({ operationId, input, options }); + return returnValue; + }; + return { execute, calls }; +} + +describe('dispatch', () => { + test('throws on unknown tool name', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('unknown_tool', {}, execute)).rejects.toThrow('Unknown tool: "unknown_tool"'); + }); + + test('has a router for every tool', () => { + const toolNames = ALL_TOOLS.map((t) => t.name); + for (const name of toolNames) { + expect(ROUTERS[name]).toBeDefined(); + } + }); +}); + +describe('routeRead', () => { + test('routes text format to getText', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_read', { format: 'text' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('getText'); + }); + + test('routes markdown format to getMarkdown', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_read', { format: 'markdown' }, execute); + expect(calls[0].operationId).toBe('getMarkdown'); + }); + + test('routes html format to getHtml', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_read', { format: 'html' }, execute); + expect(calls[0].operationId).toBe('getHtml'); + }); + + test('routes info format to info', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_read', { format: 'info' }, execute); + expect(calls[0].operationId).toBe('info'); + }); + + test('throws on unknown format', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_read', { format: 'xml' }, execute)).rejects.toThrow('Unknown format'); + }); +}); + +describe('routeFind', () => { + test('routes text search with select wrapper', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_find', { pattern: 'hello' }, execute); + expect(calls[0].operationId).toBe('find'); + expect(calls[0].input).toEqual({ select: { type: 'text', pattern: 'hello', mode: 'contains' } }); + }); + + test('routes node type search with select wrapper', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_find', { type: 'heading' }, execute); + expect(calls[0].input).toEqual({ select: { type: 'node', nodeKind: 'heading' } }); + }); + + test('routes combined pattern + type search', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_find', { pattern: 'hello', type: 'heading' }, execute); + expect(calls[0].input).toEqual({ + select: { type: 'text', pattern: 'hello', mode: 'contains', nodeKind: 'heading' }, + }); + }); + + test('includes limit and offset in query', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_find', { pattern: 'test', limit: 5, offset: 10 }, execute); + expect(calls[0].input).toEqual({ + select: { type: 'text', pattern: 'test', mode: 'contains' }, + limit: 5, + offset: 10, + }); + }); + + test('empty select when no pattern or type', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_find', {}, execute); + expect(calls[0].input).toEqual({ select: {} }); + }); +}); + +describe('routeEdit', () => { + const target = JSON.stringify({ kind: 'text', blockId: 'abc' }); + + test('routes insert', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_edit', { action: 'insert', target, text: 'hello' }, execute); + expect(calls[0].operationId).toBe('insert'); + expect(calls[0].input.value).toBe('hello'); + }); + + test('routes replace', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_edit', { action: 'replace', target, text: 'new text' }, execute); + expect(calls[0].operationId).toBe('replace'); + }); + + test('routes delete', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_edit', { action: 'delete', target }, execute); + expect(calls[0].operationId).toBe('delete'); + }); + + test('passes tracked mode when suggest=true', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_edit', { action: 'insert', target, text: 'hi', suggest: true }, execute); + expect(calls[0].options).toEqual({ changeMode: 'tracked' }); + }); + + test('no options when suggest is not set', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_edit', { action: 'insert', target, text: 'hi' }, execute); + expect(calls[0].options).toBeUndefined(); + }); +}); + +describe('routeCreate', () => { + test('routes paragraph creation', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'paragraph', text: 'Hello' }, execute); + expect(calls[0].operationId).toBe('create.paragraph'); + expect(calls[0].input.text).toBe('Hello'); + }); + + test('routes heading with level', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'heading', text: 'Title', level: 2 }, execute); + expect(calls[0].operationId).toBe('create.heading'); + expect(calls[0].input.level).toBe(2); + }); + + test('routes table with rows and columns', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'table', rows: 3, cols: 4 }, execute); + expect(calls[0].operationId).toBe('create.table'); + expect(calls[0].input.rows).toBe(3); + expect(calls[0].input.columns).toBe(4); + }); + + test('routes list creation with at', async () => { + const at = JSON.stringify({ kind: 'block', nodeId: 'p1' }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'list', kind: 'ordered', at }, execute); + expect(calls[0].operationId).toBe('create.list'); + expect(calls[0].input.kind).toBe('ordered'); + expect(calls[0].input.at).toEqual({ kind: 'block', nodeId: 'p1' }); + }); + + test('routes list creation without at as single create.list call', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'list', kind: 'bullet', text: 'Item one' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('create.list'); + expect(calls[0].input.kind).toBe('bullet'); + expect(calls[0].input.text).toBe('Item one'); + }); + + test('routes content_control with kind', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'content_control', kind: 'inline' }, execute); + expect(calls[0].operationId).toBe('create.contentControl'); + expect(calls[0].input.kind).toBe('inline'); + }); + + test('throws on unknown type', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_create', { type: 'chart' }, execute)).rejects.toThrow('Unknown block type'); + }); +}); + +describe('routeFormat', () => { + const target = JSON.stringify({ kind: 'text', blockId: 'abc' }); + + test('routes inline formatting', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, bold: true, italic: true }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('format.apply'); + expect(calls[0].input.inline).toEqual({ bold: true, italic: true }); + }); + + test('routes paragraph alignment', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, alignment: 'center' }, execute); + expect(calls[0].operationId).toBe('format.paragraph.setAlignment'); + expect(calls[0].input.alignment).toBe('center'); + }); + + test('routes paragraph spacing with correct field names', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, space_before: 12, space_after: 6 }, execute); + expect(calls[0].operationId).toBe('format.paragraph.setSpacing'); + expect(calls[0].input.before).toBe(12); + expect(calls[0].input.after).toBe(6); + }); + + test('routes named style with styleId', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, style: 'Heading1' }, execute); + expect(calls[0].operationId).toBe('styles.paragraph.setStyle'); + expect(calls[0].input.styleId).toBe('Heading1'); + }); + + test('combines multiple formatting in one call', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, bold: true, alignment: 'center', style: 'Normal' }, execute); + expect(calls).toHaveLength(3); + expect(calls[0].operationId).toBe('format.apply'); + expect(calls[1].operationId).toBe('format.paragraph.setAlignment'); + expect(calls[2].operationId).toBe('styles.paragraph.setStyle'); + }); + + test('passes tracked mode when suggest=true', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, bold: true, suggest: true }, execute); + expect(calls[0].options).toEqual({ changeMode: 'tracked' }); + }); +}); + +describe('routeComment', () => { + test('routes list', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_comment', { action: 'list' }, execute); + expect(calls[0].operationId).toBe('comments.list'); + }); + + test('routes create with target', async () => { + const target = JSON.stringify({ kind: 'text', blockId: 'x' }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_comment', { action: 'create', target, text: 'Nice work' }, execute); + expect(calls[0].operationId).toBe('comments.create'); + expect(calls[0].input.text).toBe('Nice work'); + }); + + test('create throws without target', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_comment', { action: 'create', text: 'Hi' }, execute)).rejects.toThrow( + 'Target is required', + ); + }); + + test('routes reply with parentCommentId', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_comment', { action: 'reply', comment_id: 'c1', text: 'Thanks' }, execute); + expect(calls[0].operationId).toBe('comments.create'); + expect(calls[0].input.parentCommentId).toBe('c1'); + }); + + test('routes resolve', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_comment', { action: 'resolve', comment_id: 'c1' }, execute); + expect(calls[0].operationId).toBe('comments.patch'); + expect(calls[0].input.status).toBe('resolved'); + }); + + test('routes delete', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_comment', { action: 'delete', comment_id: 'c1' }, execute); + expect(calls[0].operationId).toBe('comments.delete'); + expect(calls[0].input.commentId).toBe('c1'); + }); +}); + +describe('routeReview', () => { + test('routes list', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_review', { action: 'list' }, execute); + expect(calls[0].operationId).toBe('trackChanges.list'); + }); + + test('routes accept with id', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_review', { action: 'accept', id: 'tc1' }, execute); + expect(calls[0].operationId).toBe('trackChanges.decide'); + expect(calls[0].input).toEqual({ decision: 'accept', target: { id: 'tc1' } }); + }); + + test('routes reject with id', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_review', { action: 'reject', id: 'tc1' }, execute); + expect(calls[0].operationId).toBe('trackChanges.decide'); + expect(calls[0].input).toEqual({ decision: 'reject', target: { id: 'tc1' } }); + }); + + test('routes accept_all', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_review', { action: 'accept_all' }, execute); + expect(calls[0].input).toEqual({ decision: 'accept', target: { scope: 'all' } }); + }); + + test('routes reject_all', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_review', { action: 'reject_all' }, execute); + expect(calls[0].input).toEqual({ decision: 'reject', target: { scope: 'all' } }); + }); +}); + +describe('routeTable', () => { + const target = JSON.stringify({ kind: 'block', nodeId: 'tbl1' }); + + test('routes get', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_table', { action: 'get', target }, execute); + expect(calls[0].operationId).toBe('tables.get'); + }); + + test('routes insert_row', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_table', { action: 'insert_row', target, position: 'below' }, execute); + expect(calls[0].operationId).toBe('tables.insertRow'); + expect(calls[0].input.position).toBe('below'); + }); + + test('routes delete_row', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_table', { action: 'delete_row', target }, execute); + expect(calls[0].operationId).toBe('tables.deleteRow'); + }); + + test('routes insert_column with tableTarget and columnIndex', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_table', { action: 'insert_column', target, column_index: 2, position: 'right' }, execute); + expect(calls[0].operationId).toBe('tables.insertColumn'); + expect(calls[0].input.tableTarget).toEqual({ kind: 'block', nodeId: 'tbl1' }); + expect(calls[0].input.columnIndex).toBe(2); + expect(calls[0].input.position).toBe('right'); + }); + + test('routes merge_cells with start/end', async () => { + const { execute, calls } = mockExecutor(); + const start = { rowIndex: 0, columnIndex: 0 }; + const end = { rowIndex: 1, columnIndex: 1 }; + await dispatch('superdoc_table', { action: 'merge_cells', target, start, end }, execute); + expect(calls[0].operationId).toBe('tables.mergeCells'); + expect(calls[0].input.tableTarget).toEqual({ kind: 'block', nodeId: 'tbl1' }); + expect(calls[0].input.start).toEqual(start); + expect(calls[0].input.end).toEqual(end); + }); + + test('routes set_style with styleId', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_table', { action: 'set_style', target, style: 'TableGrid' }, execute); + expect(calls[0].operationId).toBe('tables.setStyle'); + expect(calls[0].input.styleId).toBe('TableGrid'); + }); + + test('routes sort with keys array', async () => { + const { execute, calls } = mockExecutor(); + const keys = [{ columnIndex: 0, direction: 'ascending', type: 'text' }]; + await dispatch('superdoc_table', { action: 'sort', target, keys }, execute); + expect(calls[0].operationId).toBe('tables.sort'); + expect(calls[0].input.keys).toEqual(keys); + }); +}); + +describe('routeList', () => { + const target = JSON.stringify({ kind: 'block', nodeId: 'li1' }); + + test('routes insert', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_list', { action: 'insert', target, text: 'New item', position: 'after' }, execute); + expect(calls[0].operationId).toBe('lists.insert'); + expect(calls[0].input.text).toBe('New item'); + }); + + test('routes indent', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_list', { action: 'indent', target }, execute); + expect(calls[0].operationId).toBe('lists.indent'); + }); + + test('routes outdent', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_list', { action: 'outdent', target }, execute); + expect(calls[0].operationId).toBe('lists.outdent'); + }); + + test('routes set_type', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_list', { action: 'set_type', target, kind: 'ordered' }, execute); + expect(calls[0].operationId).toBe('lists.setType'); + expect(calls[0].input.kind).toBe('ordered'); + }); + + test('routes detach', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_list', { action: 'detach', target }, execute); + expect(calls[0].operationId).toBe('lists.detach'); + }); +}); + +describe('routeImage', () => { + const target = JSON.stringify({ nodeId: 'img1' }); + + test('routes list without target', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'list' }, execute); + expect(calls[0].operationId).toBe('images.list'); + }); + + test('routes get with imageId', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'get', target }, execute); + expect(calls[0].operationId).toBe('images.get'); + expect(calls[0].input.imageId).toBe('img1'); + }); + + test('routes resize with nested size', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'resize', target, width: 200, height: 100 }, execute); + expect(calls[0].operationId).toBe('images.setSize'); + expect(calls[0].input.size).toEqual({ width: 200, height: 100 }); + }); + + test('routes set_alt_text with description field', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'set_alt_text', target, alt_text: 'A photo' }, execute); + expect(calls[0].operationId).toBe('images.setAltText'); + expect(calls[0].input.description).toBe('A photo'); + }); + + test('routes set_wrap with mapped type value', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'set_wrap', target, wrap: 'tight' }, execute); + expect(calls[0].operationId).toBe('images.setWrapType'); + expect(calls[0].input.type).toBe('Tight'); + }); + + test('routes set_wrap top_and_bottom to TopAndBottom', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'set_wrap', target, wrap: 'top_and_bottom' }, execute); + expect(calls[0].input.type).toBe('TopAndBottom'); + }); + + test('routes delete', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_image', { action: 'delete', target }, execute); + expect(calls[0].operationId).toBe('images.delete'); + }); +}); + +describe('routeSection', () => { + const target = JSON.stringify({ kind: 'section', sectionId: 's1' }); + + test('routes get_layout with address field', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'get_layout', target }, execute); + expect(calls[0].operationId).toBe('sections.get'); + expect(calls[0].input.address).toEqual({ kind: 'section', sectionId: 's1' }); + }); + + test('routes set_margins', async () => { + const { execute, calls } = mockExecutor(); + await dispatch( + 'superdoc_section', + { action: 'set_margins', target, top: 1, bottom: 1, left: 1, right: 1 }, + execute, + ); + expect(calls[0].operationId).toBe('sections.setPageMargins'); + }); + + test('routes insert_break with mapped break type', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'insert_break', break_type: 'page' }, execute); + expect(calls[0].operationId).toBe('create.sectionBreak'); + expect(calls[0].input.breakType).toBe('nextPage'); + }); + + test('routes insert_break with even/odd mapping', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'insert_break', break_type: 'even' }, execute); + expect(calls[0].input.breakType).toBe('evenPage'); + }); + + test('routes list_headers_footers with section', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'list_headers_footers', target }, execute); + expect(calls[0].operationId).toBe('headerFooters.list'); + expect(calls[0].input.section).toEqual({ kind: 'section', sectionId: 's1' }); + }); + + test('routes get_header_footer with full slot address', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'get_header_footer', target, kind: 'header', slot: 'first' }, execute); + expect(calls[0].operationId).toBe('headerFooters.get'); + expect(calls[0].input.target).toEqual({ + kind: 'headerFooterSlot', + section: { kind: 'section', sectionId: 's1' }, + headerFooterKind: 'header', + variant: 'first', + }); + }); + + test('get_header_footer defaults variant to "default"', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'get_header_footer', target, kind: 'footer' }, execute); + expect(calls[0].input.target.variant).toBe('default'); + }); +}); + +describe('routeReference', () => { + const target = JSON.stringify({ kind: 'text', blockId: 'p1' }); + + test('routes list_links', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'list_links' }, execute); + expect(calls[0].operationId).toBe('hyperlinks.list'); + }); + + test('routes insert_link with nested link spec', async () => { + const { execute, calls } = mockExecutor(); + await dispatch( + 'superdoc_reference', + { action: 'insert_link', target, url: 'https://example.com', text: 'Click' }, + execute, + ); + expect(calls[0].operationId).toBe('hyperlinks.insert'); + expect(calls[0].input.link).toEqual({ destination: { href: 'https://example.com' } }); + expect(calls[0].input.text).toBe('Click'); + }); + + test('routes update_link with parsed target address', async () => { + const id = JSON.stringify({ + kind: 'inline', + nodeType: 'hyperlink', + anchor: { start: { blockId: 'b1', offset: 0 }, end: { blockId: 'b1', offset: 5 } }, + }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'update_link', id, url: 'https://new.com' }, execute); + expect(calls[0].operationId).toBe('hyperlinks.patch'); + expect(calls[0].input.target).toEqual(JSON.parse(id)); + expect(calls[0].input.patch).toEqual({ href: 'https://new.com' }); + }); + + test('routes update_link with tooltip field', async () => { + const id = JSON.stringify({ + kind: 'inline', + nodeType: 'hyperlink', + anchor: { start: { blockId: 'b1', offset: 0 }, end: { blockId: 'b1', offset: 5 } }, + }); + const { execute, calls } = mockExecutor(); + await dispatch( + 'superdoc_reference', + { action: 'update_link', id, url: 'https://new.com', tooltip: 'Tip' }, + execute, + ); + expect(calls[0].input.patch).toEqual({ href: 'https://new.com', tooltip: 'Tip' }); + }); + + test('routes remove_link with parsed target address', async () => { + const id = JSON.stringify({ + kind: 'inline', + nodeType: 'hyperlink', + anchor: { start: { blockId: 'b1', offset: 0 }, end: { blockId: 'b1', offset: 5 } }, + }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'remove_link', id }, execute); + expect(calls[0].operationId).toBe('hyperlinks.remove'); + expect(calls[0].input.target).toEqual(JSON.parse(id)); + }); + + test('routes insert_bookmark with at field', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'insert_bookmark', target, name: 'MyBookmark' }, execute); + expect(calls[0].operationId).toBe('bookmarks.insert'); + expect(calls[0].input.at).toEqual({ kind: 'text', blockId: 'p1' }); + expect(calls[0].input.name).toBe('MyBookmark'); + }); + + test('routes remove_bookmark with parsed target', async () => { + const id = JSON.stringify({ kind: 'bookmark', bookmarkId: 'bk1' }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'remove_bookmark', id }, execute); + expect(calls[0].operationId).toBe('bookmarks.remove'); + expect(calls[0].input.target).toEqual({ kind: 'bookmark', bookmarkId: 'bk1' }); + }); + + test('routes insert_footnote with at, type, content', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'insert_footnote', target, text: 'See appendix' }, execute); + expect(calls[0].operationId).toBe('footnotes.insert'); + expect(calls[0].input.at).toEqual({ kind: 'text', blockId: 'p1' }); + expect(calls[0].input.type).toBe('footnote'); + expect(calls[0].input.content).toBe('See appendix'); + }); + + test('routes remove_footnote with parsed target', async () => { + const id = JSON.stringify({ kind: 'footnote', footnoteId: 'fn1' }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'remove_footnote', id }, execute); + expect(calls[0].operationId).toBe('footnotes.remove'); + expect(calls[0].input.target).toEqual({ kind: 'footnote', footnoteId: 'fn1' }); + }); +}); + +describe('routeControl', () => { + const target = JSON.stringify({ kind: 'block', nodeId: 'sdt1' }); + + test('routes list', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_control', { action: 'list' }, execute); + expect(calls[0].operationId).toBe('contentControls.list'); + }); + + test('routes get', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_control', { action: 'get', target }, execute); + expect(calls[0].operationId).toBe('contentControls.get'); + }); + + test('fill routes checkbox via controlType', async () => { + const { execute, calls } = mockExecutor({ controlType: 'checkbox' }); + await dispatch('superdoc_control', { action: 'fill', target, value: 'true' }, execute); + // First call: get control type; second call: set checkbox + expect(calls).toHaveLength(2); + expect(calls[0].operationId).toBe('contentControls.get'); + expect(calls[1].operationId).toBe('contentControls.checkbox.setState'); + expect(calls[1].input.checked).toBe(true); + }); + + test('fill routes dropDownList via controlType', async () => { + const { execute, calls } = mockExecutor({ controlType: 'dropDownList' }); + await dispatch('superdoc_control', { action: 'fill', target, value: 'Option A' }, execute); + expect(calls[1].operationId).toBe('contentControls.choiceList.setSelected'); + expect(calls[1].input.value).toBe('Option A'); + }); + + test('fill routes date via controlType', async () => { + const { execute, calls } = mockExecutor({ controlType: 'date' }); + await dispatch('superdoc_control', { action: 'fill', target, value: '2026-01-01' }, execute); + expect(calls[1].operationId).toBe('contentControls.date.setValue'); + }); + + test('fill defaults to text for unknown controlType', async () => { + const { execute, calls } = mockExecutor({ controlType: 'text' }); + await dispatch('superdoc_control', { action: 'fill', target, value: 'Hello' }, execute); + expect(calls[1].operationId).toBe('contentControls.text.setValue'); + }); + + test('routes wrap with kind field', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_control', { action: 'wrap', target, kind: 'inline' }, execute); + expect(calls[0].operationId).toBe('contentControls.wrap'); + expect(calls[0].input.kind).toBe('inline'); + }); + + test('wrap defaults kind to block', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_control', { action: 'wrap', target }, execute); + expect(calls[0].input.kind).toBe('block'); + }); +}); + +describe('parseTarget error handling', () => { + test('throws on malformed JSON target', async () => { + const { execute } = mockExecutor(); + await expect( + dispatch('superdoc_edit', { action: 'insert', target: '{ broken json', text: 'hi' }, execute), + ).rejects.toThrow('Invalid JSON in "target"'); + }); + + test('throws on malformed JSON id', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_reference', { action: 'remove_link', id: 'not json' }, execute)).rejects.toThrow( + 'Invalid JSON in "id"', + ); + }); +}); + +describe('routeCreate (additional branches)', () => { + test('routes image creation', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'image', src: 'https://example.com/img.png' }, execute); + expect(calls[0].operationId).toBe('create.image'); + expect(calls[0].input.src).toBe('https://example.com/img.png'); + }); + + test('routes section_break creation', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'section_break' }, execute); + expect(calls[0].operationId).toBe('create.sectionBreak'); + }); + + test('routes toc creation', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'toc' }, execute); + expect(calls[0].operationId).toBe('create.tableOfContents'); + }); + + test('passes at parameter when provided', async () => { + const at = JSON.stringify({ kind: 'block', nodeId: 'p1' }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_create', { type: 'image', src: 'img.png', at }, execute); + expect(calls[0].input.at).toEqual({ kind: 'block', nodeId: 'p1' }); + }); +}); + +describe('routeFormat (additional branches)', () => { + const target = JSON.stringify({ kind: 'text', blockId: 'abc' }); + + test('routes bold:false as off', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, bold: false }, execute); + expect(calls[0].operationId).toBe('format.apply'); + expect(calls[0].input.inline).toEqual({ bold: false }); + }); + + test('routes line_spacing to line field', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, line_spacing: 1.5 }, execute); + expect(calls[0].operationId).toBe('format.paragraph.setSpacing'); + expect(calls[0].input.line).toBe(1.5); + }); + + test('routes indentation', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_format', { target, indent_left: 36, indent_right: 18 }, execute); + expect(calls[0].operationId).toBe('format.paragraph.setIndentation'); + expect(calls[0].input.left).toBe(36); + expect(calls[0].input.right).toBe(18); + }); +}); + +describe('routeSection (additional branches)', () => { + const target = JSON.stringify({ kind: 'section', sectionId: 's1' }); + + test('routes set_orientation', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'set_orientation', target, orientation: 'landscape' }, execute); + expect(calls[0].operationId).toBe('sections.setPageSetup'); + expect(calls[0].input.orientation).toBe('landscape'); + }); + + test('routes set_size', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_section', { action: 'set_size', target, width: 612, height: 792 }, execute); + expect(calls[0].operationId).toBe('sections.setPageSetup'); + expect(calls[0].input.width).toBe(612); + expect(calls[0].input.height).toBe(792); + }); + + test('get_header_footer throws without kind', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_section', { action: 'get_header_footer', target }, execute)).rejects.toThrow( + 'requires "kind"', + ); + }); +}); + +describe('routeTable (additional branches)', () => { + const target = JSON.stringify({ kind: 'block', nodeId: 'tbl1' }); + + test('routes delete_column with tableTarget and columnIndex', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_table', { action: 'delete_column', target, column_index: 1 }, execute); + expect(calls[0].operationId).toBe('tables.deleteColumn'); + expect(calls[0].input.tableTarget).toEqual({ kind: 'block', nodeId: 'tbl1' }); + expect(calls[0].input.columnIndex).toBe(1); + }); +}); + +describe('routeReference (additional branches)', () => { + test('routes list_bookmarks', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'list_bookmarks' }, execute); + expect(calls[0].operationId).toBe('bookmarks.list'); + }); + + test('routes list_footnotes', async () => { + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'list_footnotes' }, execute); + expect(calls[0].operationId).toBe('footnotes.list'); + }); + + test('insert_bookmark throws without target', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_reference', { action: 'insert_bookmark', name: 'bk' }, execute)).rejects.toThrow( + 'requires a "target"', + ); + }); + + test('insert_footnote throws without target', async () => { + const { execute } = mockExecutor(); + await expect(dispatch('superdoc_reference', { action: 'insert_footnote', text: 'note' }, execute)).rejects.toThrow( + 'requires a "target"', + ); + }); + + test('update_link only includes href when url is provided', async () => { + const id = JSON.stringify({ + kind: 'inline', + nodeType: 'hyperlink', + anchor: { start: { blockId: 'b1', offset: 0 }, end: { blockId: 'b1', offset: 5 } }, + }); + const { execute, calls } = mockExecutor(); + await dispatch('superdoc_reference', { action: 'update_link', id, tooltip: 'New tip' }, execute); + expect(calls[0].input.patch).toEqual({ tooltip: 'New tip' }); + expect(calls[0].input.patch).not.toHaveProperty('href'); + }); +}); + +// --------------------------------------------------------------------------- +// Content address auto-resolution +// --------------------------------------------------------------------------- + +describe('content address auto-resolution', () => { + /** Executor that returns a mock SDNodeResult when getNodeById is called. */ + function nodeAwareExecutor(nodeText: string, returnValue: unknown = { ok: true }) { + const calls: Array<{ operationId: string; input: Record; options?: Record }> = []; + const execute: Executor = async (operationId, input, options) => { + calls.push({ operationId, input, options }); + if (operationId === 'getNodeById') { + return { + node: { + kind: 'paragraph', + id: (input as Record).nodeId, + paragraph: { + inlines: [{ kind: 'run', run: { text: nodeText } }], + }, + }, + address: { kind: 'content', stability: 'stable', nodeId: (input as Record).nodeId }, + }; + } + return returnValue; + }; + return { execute, calls }; + } + + test('format: resolves content address to text address', async () => { + const contentTarget = JSON.stringify({ kind: 'content', stability: 'stable', nodeId: 'node-1' }); + const { execute, calls } = nodeAwareExecutor('Revenue'); + await dispatch('superdoc_format', { target: contentTarget, bold: true }, execute); + + // First call should be getNodeById to resolve the address + expect(calls[0].operationId).toBe('getNodeById'); + expect(calls[0].input.nodeId).toBe('node-1'); + + // Second call should be format.apply with resolved text address + expect(calls[1].operationId).toBe('format.apply'); + expect(calls[1].input.target).toEqual({ kind: 'text', blockId: 'node-1', range: { start: 0, end: 7 } }); + }); + + test('format: passes text address through unchanged', async () => { + const textTarget = JSON.stringify({ kind: 'text', blockId: 'node-1', range: { start: 0, end: 5 } }); + const { execute, calls } = nodeAwareExecutor('Hello'); + await dispatch('superdoc_format', { target: textTarget, bold: true }, execute); + + // Should NOT call getNodeById — text address passes through + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('format.apply'); + expect(calls[0].input.target).toEqual({ kind: 'text', blockId: 'node-1', range: { start: 0, end: 5 } }); + }); + + test('comment create: resolves content address to text address', async () => { + const contentTarget = JSON.stringify({ kind: 'content', stability: 'stable', nodeId: 'para-1' }); + const { execute, calls } = nodeAwareExecutor('Hello world'); + await dispatch('superdoc_comment', { action: 'create', target: contentTarget, text: 'Nice!' }, execute); + + expect(calls[0].operationId).toBe('getNodeById'); + expect(calls[1].operationId).toBe('comments.create'); + expect(calls[1].input.target).toEqual({ kind: 'text', blockId: 'para-1', range: { start: 0, end: 11 } }); + }); +}); + +// --------------------------------------------------------------------------- +// Find result enrichment +// --------------------------------------------------------------------------- + +describe('find result enrichment', () => { + /** Returns a mock find result with paragraph nodes. */ + function findResultWithParagraphs() { + return { + total: 2, + limit: 10, + offset: 0, + items: [ + { + node: { + kind: 'paragraph', + id: 'p1', + paragraph: { inlines: [{ kind: 'run', run: { text: 'Hello World' } }] }, + }, + address: { kind: 'content', stability: 'stable', nodeId: 'p1' }, + }, + { + node: { + kind: 'heading', + id: 'h1', + heading: { level: 1, inlines: [{ kind: 'run', run: { text: 'Title' } }] }, + }, + address: { kind: 'content', stability: 'stable', nodeId: 'h1' }, + }, + ], + }; + } + + test('adds textAddress to each find result', async () => { + const { execute, calls } = mockExecutor(findResultWithParagraphs()); + const result = (await dispatch('superdoc_find', { type: 'paragraph' }, execute)) as Record; + const items = result.items as Array>; + + expect(items[0].textAddress).toEqual({ kind: 'text', blockId: 'p1', range: { start: 0, end: 11 } }); + expect(items[1].textAddress).toEqual({ kind: 'text', blockId: 'h1', range: { start: 0, end: 5 } }); + }); + + test('adds matchAddress for text searches', async () => { + const findResult = { + total: 1, + limit: 10, + offset: 0, + items: [ + { + node: { + kind: 'paragraph', + id: 'p1', + paragraph: { inlines: [{ kind: 'run', run: { text: 'The Revenue report' } }] }, + }, + address: { kind: 'content', stability: 'stable', nodeId: 'p1' }, + }, + ], + }; + const { execute } = mockExecutor(findResult); + const result = (await dispatch('superdoc_find', { pattern: 'Revenue' }, execute)) as Record; + const items = result.items as Array>; + + // matchAddress should point to "Revenue" starting at offset 4 + expect(items[0].matchAddress).toEqual({ kind: 'text', blockId: 'p1', range: { start: 4, end: 11 } }); + // textAddress covers full block + expect(items[0].textAddress).toEqual({ kind: 'text', blockId: 'p1', range: { start: 0, end: 18 } }); + }); + + test('no matchAddress for node-type searches', async () => { + const { execute } = mockExecutor(findResultWithParagraphs()); + const result = (await dispatch('superdoc_find', { type: 'paragraph' }, execute)) as Record; + const items = result.items as Array>; + + expect(items[0].matchAddress).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Table set_cells +// --------------------------------------------------------------------------- + +describe('routeTable set_cells', () => { + /** Executor that returns a table node when getNodeById is called. */ + function tableAwareExecutor() { + const calls: Array<{ operationId: string; input: Record; options?: Record }> = []; + const execute: Executor = async (operationId, input, options) => { + calls.push({ operationId, input, options }); + if (operationId === 'getNodeById') { + return { + node: { + kind: 'table', + id: 'tbl-1', + table: { + rows: [ + { + cells: [ + { content: [{ kind: 'paragraph', id: 'c00', paragraph: { inlines: [] } }] }, + { content: [{ kind: 'paragraph', id: 'c01', paragraph: { inlines: [] } }] }, + ], + }, + { + cells: [ + { content: [{ kind: 'paragraph', id: 'c10', paragraph: { inlines: [] } }] }, + { content: [{ kind: 'paragraph', id: 'c11', paragraph: { inlines: [] } }] }, + ], + }, + ], + }, + }, + }; + } + return { success: true }; + }; + return { execute, calls }; + } + + test('populates all cells in a 2x2 table', async () => { + const target = JSON.stringify({ kind: 'content', nodeId: 'tbl-1' }); + const { execute, calls } = tableAwareExecutor(); + const result = (await dispatch( + 'superdoc_table', + { + action: 'set_cells', + target, + data: [ + ['A', 'B'], + ['C', 'D'], + ], + }, + execute, + )) as Record; + + // 1 getNodeById + 4 insert calls + expect(calls).toHaveLength(5); + expect(calls[0].operationId).toBe('getNodeById'); + expect(calls[1].operationId).toBe('insert'); + expect(calls[1].input.value).toBe('A'); + expect(calls[1].input.target).toEqual({ kind: 'text', blockId: 'c00', range: { start: 0, end: 0 } }); + expect(calls[4].input.value).toBe('D'); + expect(calls[4].input.target).toEqual({ kind: 'text', blockId: 'c11', range: { start: 0, end: 0 } }); + + expect(result.success).toBe(true); + expect(result.cellsSet).toBe(4); + }); + + test('skips null/empty cells', async () => { + const target = JSON.stringify({ kind: 'content', nodeId: 'tbl-1' }); + const { execute, calls } = tableAwareExecutor(); + await dispatch( + 'superdoc_table', + { + action: 'set_cells', + target, + data: [ + ['A', ''], + [null, 'D'], + ], + }, + execute, + ); + + // 1 getNodeById + 2 insert calls (skipped empty and null) + expect(calls).toHaveLength(3); + expect(calls[1].input.value).toBe('A'); + expect(calls[2].input.value).toBe('D'); + }); +}); + +// --------------------------------------------------------------------------- +// Multi-item list creation +// --------------------------------------------------------------------------- + +describe('routeCreate list with items', () => { + test('creates list then inserts additional items', async () => { + let callIndex = 0; + const calls: Array<{ operationId: string; input: Record }> = []; + const execute: Executor = async (operationId, input) => { + calls.push({ operationId, input: input as Record }); + callIndex++; + if (operationId === 'create.list') { + return { success: true, listId: '1:item-0', item: { kind: 'block', nodeType: 'listItem', nodeId: 'item-0' } }; + } + if (operationId === 'lists.insert') { + return { success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: `item-${callIndex}` } }; + } + return { success: true }; + }; + + const result = (await dispatch( + 'superdoc_create', + { type: 'list', kind: 'bullet', items: ['First', 'Second', 'Third'] }, + execute, + )) as Record; + + // 1 create.list + 2 lists.insert + expect(calls).toHaveLength(3); + expect(calls[0].operationId).toBe('create.list'); + expect(calls[0].input.text).toBe('First'); + + expect(calls[1].operationId).toBe('lists.insert'); + expect(calls[1].input.text).toBe('Second'); + expect(calls[1].input.position).toBe('after'); + + expect(calls[2].operationId).toBe('lists.insert'); + expect(calls[2].input.text).toBe('Third'); + + expect(result.success).toBe(true); + expect(result.listId).toBe('1:item-0'); + expect((result.items as unknown[]).length).toBe(3); + }); + + test('single text still uses simple create.list', async () => { + const { execute, calls } = mockExecutor({ success: true, listId: '1:x', item: { nodeId: 'x' } }); + await dispatch('superdoc_create', { type: 'list', text: 'Only one' }, execute); + expect(calls).toHaveLength(1); + expect(calls[0].operationId).toBe('create.list'); + expect(calls[0].input.text).toBe('Only one'); + }); +}); diff --git a/packages/llm-tools/src/definitions/comment.ts b/packages/llm-tools/src/definitions/comment.ts new file mode 100644 index 0000000000..1d39631133 --- /dev/null +++ b/packages/llm-tools/src/definitions/comment.ts @@ -0,0 +1,30 @@ +import type { ToolDefinition } from '../types.js'; + +export const commentTool: ToolDefinition = { + name: 'superdoc_comment', + description: + 'Add, list, reply to, resolve, or delete comments. ' + + 'Use superdoc_find first to get a target address for creating new comments.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: ['list', 'create', 'reply', 'resolve', 'delete'], + description: 'The comment operation to perform.', + }, + target: { + type: 'string', + description: + 'JSON address to anchor the comment to. For action "create". Accepts both text addresses (with range) and ' + + 'content addresses (just nodeId) — content addresses are auto-resolved to cover the full block text.', + }, + text: { type: 'string', description: 'Comment body. For actions "create" and "reply".' }, + comment_id: { type: 'string', description: 'Comment ID. For actions "reply", "resolve", and "delete".' }, + include_resolved: { type: 'boolean', description: 'Include resolved comments in results. For action "list".' }, + }, + required: ['session_id', 'action'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/control.ts b/packages/llm-tools/src/definitions/control.ts new file mode 100644 index 0000000000..2fdd3b07cd --- /dev/null +++ b/packages/llm-tools/src/definitions/control.ts @@ -0,0 +1,32 @@ +import type { ToolDefinition } from '../types.js'; + +export const controlTool: ToolDefinition = { + name: 'superdoc_control', + description: + 'Work with form fields (content controls): text inputs, checkboxes, dropdowns, date pickers. ' + + 'Use action "list" to find all form fields, then "fill" to set values. ' + + 'The "fill" action auto-detects the control type — just provide a value.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: ['list', 'get', 'fill', 'wrap'], + description: 'The content control operation to perform.', + }, + target: { type: 'string', description: 'JSON content control address.' }, + value: { + description: + 'Value to set. Accepts text string, true/false for checkboxes, or selection value for dropdowns. For action "fill".', + }, + kind: { + type: 'string', + enum: ['block', 'inline'], + description: 'Content control scope. For action "wrap". Defaults to "block".', + }, + }, + required: ['session_id', 'action'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/create.ts b/packages/llm-tools/src/definitions/create.ts new file mode 100644 index 0000000000..2c6796c33c --- /dev/null +++ b/packages/llm-tools/src/definitions/create.ts @@ -0,0 +1,39 @@ +import type { ToolDefinition } from '../types.js'; + +export const createTool: ToolDefinition = { + name: 'superdoc_create', + description: + 'Create a new block element in the document: paragraph, heading, table, image, list, section break, ' + + 'table of contents, or content control. Appends to the end if no position is specified.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + type: { + type: 'string', + enum: ['paragraph', 'heading', 'table', 'image', 'list', 'section_break', 'toc', 'content_control'], + description: 'Type of block element to create.', + }, + at: { type: 'string', description: 'JSON position address. Appends to end if omitted.' }, + text: { type: 'string', description: 'Initial text content for the block.' }, + level: { + type: 'number', + minimum: 1, + maximum: 6, + description: 'Heading level (1-6). Required for type "heading".', + }, + rows: { type: 'number', minimum: 1, description: 'Number of rows. For type "table".' }, + cols: { type: 'number', minimum: 1, description: 'Number of columns. For type "table".' }, + src: { type: 'string', description: 'Image file path or URL. For type "image".' }, + kind: { type: 'string', enum: ['ordered', 'bullet'], description: 'List kind. For type "list".' }, + items: { + type: 'array', + items: { type: 'string' }, + description: 'For type "list": create a list with multiple items at once. e.g. ["First", "Second", "Third"].', + }, + suggest: { type: 'boolean', description: 'If true, creates as a tracked change.' }, + }, + required: ['session_id', 'type'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/edit.ts b/packages/llm-tools/src/definitions/edit.ts new file mode 100644 index 0000000000..4e84a3012e --- /dev/null +++ b/packages/llm-tools/src/definitions/edit.ts @@ -0,0 +1,28 @@ +import type { ToolDefinition } from '../types.js'; + +export const editTool: ToolDefinition = { + name: 'superdoc_edit', + description: + 'Insert, replace, or delete text in the document. ' + + 'Use superdoc_find first to get a target address. ' + + 'Set suggest=true to create a tracked change instead of a direct edit.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: ['insert', 'replace', 'delete'], + description: 'The edit operation to perform.', + }, + target: { type: 'string', description: 'JSON address from superdoc_find results.' }, + text: { type: 'string', description: 'Text content to insert or replace with. Required for insert and replace.' }, + suggest: { + type: 'boolean', + description: 'If true, creates a tracked change (suggestion) instead of a direct edit.', + }, + }, + required: ['session_id', 'action', 'target'], + }, + annotations: { readOnlyHint: false, destructiveHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/find.ts b/packages/llm-tools/src/definitions/find.ts new file mode 100644 index 0000000000..c11e8dc453 --- /dev/null +++ b/packages/llm-tools/src/definitions/find.ts @@ -0,0 +1,21 @@ +import type { ToolDefinition } from '../types.js'; + +export const findTool: ToolDefinition = { + name: 'superdoc_find', + description: + 'Search for content in the document. Returns addresses that other tools need for edits. ' + + 'Each result includes a "textAddress" (covers full block text) and, for text searches, a "matchAddress" ' + + '(exact range of the match). Pass these directly to superdoc_format or superdoc_comment as the target.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + pattern: { type: 'string', description: 'Text or regex pattern to search for.' }, + type: { type: 'string', description: 'Node type to filter by (e.g. "paragraph", "heading", "table").' }, + limit: { type: 'number', description: 'Maximum number of results to return.' }, + offset: { type: 'number', description: 'Number of results to skip.' }, + }, + required: ['session_id'], + }, + annotations: { readOnlyHint: true, idempotentHint: true }, +}; diff --git a/packages/llm-tools/src/definitions/format.ts b/packages/llm-tools/src/definitions/format.ts new file mode 100644 index 0000000000..6d324e0387 --- /dev/null +++ b/packages/llm-tools/src/definitions/format.ts @@ -0,0 +1,47 @@ +import type { ToolDefinition } from '../types.js'; + +export const formatTool: ToolDefinition = { + name: 'superdoc_format', + description: + 'Change how content looks. Supports inline styles (bold, italic, font, size, color), ' + + 'paragraph formatting (alignment, spacing, indentation), and named styles. ' + + 'Provide any combination of properties — they are applied together. ' + + 'Use superdoc_find first to get a target address.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + target: { + type: 'string', + description: + 'JSON address from superdoc_find. Accepts both text addresses (with range) and content addresses ' + + '(just nodeId) — content addresses are auto-resolved to cover the full block text.', + }, + suggest: { type: 'boolean', description: 'If true, creates a tracked change.' }, + // Inline formatting + bold: { type: 'boolean', description: 'Set or remove bold.' }, + italic: { type: 'boolean', description: 'Set or remove italic.' }, + underline: { type: 'boolean', description: 'Set or remove underline.' }, + strikethrough: { type: 'boolean', description: 'Set or remove strikethrough.' }, + font: { type: 'string', description: 'Font family name (e.g. "Arial", "Times New Roman").' }, + size: { type: 'number', description: 'Font size in points.' }, + color: { type: 'string', description: 'Text color as hex (e.g. "#FF0000") or name.' }, + highlight: { type: 'string', description: 'Highlight/background color.' }, + // Paragraph formatting + alignment: { + type: 'string', + enum: ['left', 'center', 'right', 'justified'], + description: 'Paragraph alignment.', + }, + line_spacing: { type: 'number', description: 'Line spacing multiplier.' }, + space_before: { type: 'number', description: 'Space before paragraph in points.' }, + space_after: { type: 'number', description: 'Space after paragraph in points.' }, + indent_left: { type: 'number', description: 'Left indentation in points.' }, + indent_right: { type: 'number', description: 'Right indentation in points.' }, + // Named style + style: { type: 'string', description: 'Named style to apply (e.g. "Heading 1", "Normal", "Title").' }, + }, + required: ['session_id', 'target'], + }, + annotations: { readOnlyHint: false, idempotentHint: true }, +}; diff --git a/packages/llm-tools/src/definitions/image.ts b/packages/llm-tools/src/definitions/image.ts new file mode 100644 index 0000000000..9ad5812800 --- /dev/null +++ b/packages/llm-tools/src/definitions/image.ts @@ -0,0 +1,30 @@ +import type { ToolDefinition } from '../types.js'; + +export const imageTool: ToolDefinition = { + name: 'superdoc_image', + description: + 'Work with images in the document. Use action "list" to find all images. ' + + 'To insert a new image, use superdoc_create with type "image" instead.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: ['list', 'get', 'resize', 'set_alt_text', 'set_wrap', 'delete'], + description: 'The image operation to perform.', + }, + target: { type: 'string', description: 'JSON image address. Required for all actions except "list".' }, + width: { type: 'number', description: 'Width in points. For action "resize".' }, + height: { type: 'number', description: 'Height in points. For action "resize".' }, + alt_text: { type: 'string', description: 'Alt text for accessibility. For action "set_alt_text".' }, + wrap: { + type: 'string', + enum: ['inline', 'square', 'tight', 'through', 'top_and_bottom', 'none'], + description: 'Text wrap type. For action "set_wrap".', + }, + }, + required: ['session_id', 'action'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/index.ts b/packages/llm-tools/src/definitions/index.ts new file mode 100644 index 0000000000..ff980b789e --- /dev/null +++ b/packages/llm-tools/src/definitions/index.ts @@ -0,0 +1,48 @@ +import type { ToolDefinition } from '../types.js'; + +import { readTool } from './read.js'; +import { findTool } from './find.js'; +import { editTool } from './edit.js'; +import { createTool } from './create.js'; +import { formatTool } from './format.js'; +import { tableTool } from './table.js'; +import { listTool } from './list.js'; +import { imageTool } from './image.js'; +import { commentTool } from './comment.js'; +import { reviewTool } from './review.js'; +import { sectionTool } from './section.js'; +import { referenceTool } from './reference.js'; +import { controlTool } from './control.js'; + +export { + readTool, + findTool, + editTool, + createTool, + formatTool, + tableTool, + listTool, + imageTool, + commentTool, + reviewTool, + sectionTool, + referenceTool, + controlTool, +}; + +/** All 13 intent-based tool definitions (excludes lifecycle tools which are transport-specific). */ +export const ALL_TOOLS: readonly ToolDefinition[] = [ + readTool, + findTool, + editTool, + createTool, + formatTool, + tableTool, + listTool, + imageTool, + commentTool, + reviewTool, + sectionTool, + referenceTool, + controlTool, +]; diff --git a/packages/llm-tools/src/definitions/list.ts b/packages/llm-tools/src/definitions/list.ts new file mode 100644 index 0000000000..63f975ca2f --- /dev/null +++ b/packages/llm-tools/src/definitions/list.ts @@ -0,0 +1,29 @@ +import type { ToolDefinition } from '../types.js'; + +export const listTool: ToolDefinition = { + name: 'superdoc_list', + description: + 'Work with bullet and numbered lists. Use superdoc_find to locate list items first. ' + + 'To create a new list, use superdoc_create with type "list" instead.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: ['insert', 'indent', 'outdent', 'set_type', 'detach'], + description: 'The list operation to perform.', + }, + target: { type: 'string', description: 'JSON list item address from superdoc_find results.' }, + text: { type: 'string', description: 'Text for new list item. For action "insert".' }, + position: { + type: 'string', + enum: ['before', 'after'], + description: 'Where to insert relative to target. For action "insert".', + }, + kind: { type: 'string', enum: ['ordered', 'bullet'], description: 'List type. For action "set_type".' }, + }, + required: ['session_id', 'action', 'target'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/read.ts b/packages/llm-tools/src/definitions/read.ts new file mode 100644 index 0000000000..78eaf77393 --- /dev/null +++ b/packages/llm-tools/src/definitions/read.ts @@ -0,0 +1,21 @@ +import type { ToolDefinition } from '../types.js'; + +export const readTool: ToolDefinition = { + name: 'superdoc_read', + description: + 'Read document content. Use format "markdown" to see headings, lists, tables, and links. ' + + 'Use "text" for plain text. Use "html" for HTML. Use "info" for metadata and structure summary.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + format: { + type: 'string', + enum: ['text', 'markdown', 'html', 'info'], + description: 'Output format. "markdown" is recommended for understanding document structure.', + }, + }, + required: ['session_id', 'format'], + }, + annotations: { readOnlyHint: true, idempotentHint: true }, +}; diff --git a/packages/llm-tools/src/definitions/reference.ts b/packages/llm-tools/src/definitions/reference.ts new file mode 100644 index 0000000000..e28541a760 --- /dev/null +++ b/packages/llm-tools/src/definitions/reference.ts @@ -0,0 +1,41 @@ +import type { ToolDefinition } from '../types.js'; + +export const referenceTool: ToolDefinition = { + name: 'superdoc_reference', + description: + 'Manage hyperlinks, bookmarks, and footnotes. ' + + 'Use superdoc_find to get a target address for inserting new references.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: [ + 'list_links', + 'insert_link', + 'update_link', + 'remove_link', + 'list_bookmarks', + 'insert_bookmark', + 'remove_bookmark', + 'list_footnotes', + 'insert_footnote', + 'remove_footnote', + ], + description: 'The reference operation to perform.', + }, + target: { type: 'string', description: 'JSON address for insertions.' }, + url: { type: 'string', description: 'URL for hyperlink actions.' }, + text: { type: 'string', description: 'Display text for insert_link, or footnote content for insert_footnote.' }, + tooltip: { type: 'string', description: 'Tooltip text for update_link.' }, + name: { type: 'string', description: 'Bookmark name.' }, + id: { + type: 'string', + description: 'JSON address of the reference (from list results) for update/remove actions.', + }, + }, + required: ['session_id', 'action'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/review.ts b/packages/llm-tools/src/definitions/review.ts new file mode 100644 index 0000000000..b309d47d86 --- /dev/null +++ b/packages/llm-tools/src/definitions/review.ts @@ -0,0 +1,29 @@ +import type { ToolDefinition } from '../types.js'; + +export const reviewTool: ToolDefinition = { + name: 'superdoc_review', + description: + 'Review tracked changes (suggestions). List all pending changes, then accept or reject them individually or all at once.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: ['list', 'accept', 'reject', 'accept_all', 'reject_all'], + description: 'The review operation to perform.', + }, + id: { + type: 'string', + description: 'Change ID from a previous "list" result. For actions "accept" and "reject".', + }, + type: { + type: 'string', + enum: ['insert', 'delete', 'format'], + description: 'Filter changes by type. For action "list".', + }, + }, + required: ['session_id', 'action'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/section.ts b/packages/llm-tools/src/definitions/section.ts new file mode 100644 index 0000000000..aa54275f00 --- /dev/null +++ b/packages/llm-tools/src/definitions/section.ts @@ -0,0 +1,60 @@ +import type { ToolDefinition } from '../types.js'; + +export const sectionTool: ToolDefinition = { + name: 'superdoc_section', + description: + 'Configure page layout, sections, and headers/footers. ' + + 'Use action "get_layout" to see current page setup before making changes.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: [ + 'get_layout', + 'set_margins', + 'set_orientation', + 'set_size', + 'insert_break', + 'list_headers_footers', + 'get_header_footer', + ], + description: 'The section/layout operation to perform.', + }, + target: { type: 'string', description: 'JSON section address. Defaults to first section if omitted.' }, + // Margins + top: { type: 'number', description: 'Top margin in points. For action "set_margins".' }, + bottom: { type: 'number', description: 'Bottom margin in points. For action "set_margins".' }, + left: { type: 'number', description: 'Left margin in points. For action "set_margins".' }, + right: { type: 'number', description: 'Right margin in points. For action "set_margins".' }, + // Orientation & size + orientation: { + type: 'string', + enum: ['portrait', 'landscape'], + description: 'Page orientation. For action "set_orientation".', + }, + width: { type: 'number', description: 'Page width in points. For action "set_size".' }, + height: { type: 'number', description: 'Page height in points. For action "set_size".' }, + // Section break + break_type: { + type: 'string', + enum: ['page', 'continuous', 'even', 'odd'], + description: 'Break type. For action "insert_break".', + }, + // Header/footer + slot: { + type: 'string', + enum: ['default', 'first', 'even'], + description: 'Header/footer slot. For header/footer actions.', + }, + kind: { + type: 'string', + enum: ['header', 'footer'], + description: 'Header or footer. Required for "get_header_footer" and "list_headers_footers".', + }, + }, + required: ['session_id', 'action'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/definitions/table.ts b/packages/llm-tools/src/definitions/table.ts new file mode 100644 index 0000000000..6c721f9b00 --- /dev/null +++ b/packages/llm-tools/src/definitions/table.ts @@ -0,0 +1,67 @@ +import type { ToolDefinition } from '../types.js'; + +export const tableTool: ToolDefinition = { + name: 'superdoc_table', + description: + 'Read or modify a table. Use superdoc_find with type "table" to get the table address first. ' + + 'Supports getting table info, inserting/deleting rows and columns, merging cells, styling, and sorting.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID from superdoc_open.' }, + action: { + type: 'string', + enum: [ + 'get', + 'set_cells', + 'insert_row', + 'delete_row', + 'insert_column', + 'delete_column', + 'merge_cells', + 'set_style', + 'sort', + ], + description: 'The table operation to perform.', + }, + target: { type: 'string', description: 'JSON table address from superdoc_find results.' }, + data: { + type: 'array', + items: { type: 'array', items: { type: 'string' } }, + description: + 'For set_cells: 2D array of cell values. Row-major order, e.g. [["A1","B1"],["A2","B2"]]. ' + + 'Fills cells from the table node; skips null/empty values.', + }, + position: { + type: 'string', + description: 'Position for insert operations (e.g. "above", "below", "left", "right").', + }, + column_index: { type: 'number', description: 'Column index for insert_column/delete_column.' }, + start: { + type: 'object', + properties: { rowIndex: { type: 'number' }, columnIndex: { type: 'number' } }, + description: 'Start cell for merge_cells. e.g. {"rowIndex": 0, "columnIndex": 0}', + }, + end: { + type: 'object', + properties: { rowIndex: { type: 'number' }, columnIndex: { type: 'number' } }, + description: 'End cell for merge_cells. e.g. {"rowIndex": 1, "columnIndex": 2}', + }, + style: { type: 'string', description: 'Table style name for set_style.' }, + keys: { + type: 'array', + items: { + type: 'object', + properties: { + columnIndex: { type: 'number' }, + direction: { type: 'string', enum: ['ascending', 'descending'] }, + type: { type: 'string', enum: ['text', 'number', 'date'] }, + }, + }, + description: 'Sort keys for action "sort". e.g. [{"columnIndex": 0, "direction": "ascending", "type": "text"}]', + }, + }, + required: ['session_id', 'action', 'target'], + }, + annotations: { readOnlyHint: false }, +}; diff --git a/packages/llm-tools/src/index.ts b/packages/llm-tools/src/index.ts new file mode 100644 index 0000000000..870dcaa8c6 --- /dev/null +++ b/packages/llm-tools/src/index.ts @@ -0,0 +1,56 @@ +export type { ToolDefinition, ToolAnnotations, JsonSchema, Executor, ToolRouter } from './types.js'; +export { ALL_TOOLS } from './definitions/index.js'; +export { ROUTERS } from './router/index.js'; + +// Re-export individual definitions +export { + readTool, + findTool, + editTool, + createTool, + formatTool, + tableTool, + listTool, + imageTool, + commentTool, + reviewTool, + sectionTool, + referenceTool, + controlTool, +} from './definitions/index.js'; + +// Re-export individual routers +export { + routeRead, + routeFind, + routeEdit, + routeCreate, + routeFormat, + routeTable, + routeList, + routeImage, + routeComment, + routeReview, + routeSection, + routeReference, + routeControl, +} from './router/index.js'; + +import type { Executor } from './types.js'; +import { ALL_TOOLS } from './definitions/index.js'; +import { ROUTERS } from './router/index.js'; + +/** + * Dispatch a tool call through the routing layer. + * + * @param toolName - The tool name (e.g. "superdoc_edit") + * @param params - The parameters the LLM provided (minus session_id, which the consumer handles) + * @param execute - Transport-agnostic executor that calls Document API operations + */ +export async function dispatch(toolName: string, params: Record, execute: Executor): Promise { + const router = ROUTERS[toolName]; + if (!router) { + throw new Error(`Unknown tool: "${toolName}". Available tools: ${ALL_TOOLS.map((t) => t.name).join(', ')}`); + } + return router(params, execute); +} diff --git a/packages/llm-tools/src/router/comment.ts b/packages/llm-tools/src/router/comment.ts new file mode 100644 index 0000000000..d3e8b372bc --- /dev/null +++ b/packages/llm-tools/src/router/comment.ts @@ -0,0 +1,29 @@ +import type { Executor } from '../types.js'; +import { parseTarget, resolveTextTarget } from './utils.js'; + +export async function routeComment(params: Record, execute: Executor) { + const action = params.action as string; + + switch (action) { + case 'list': { + const input: Record = {}; + if (params.include_resolved) input.includeResolved = params.include_resolved; + return execute('comments.list', input); + } + case 'create': { + const rawTarget = parseTarget(params); + if (!rawTarget) + throw new Error('Target is required for "create" action. Use superdoc_find first to get a target address.'); + const target = await resolveTextTarget(rawTarget, execute); + return execute('comments.create', { text: params.text, target }); + } + case 'reply': + return execute('comments.create', { parentCommentId: params.comment_id, text: params.text }); + case 'resolve': + return execute('comments.patch', { commentId: params.comment_id, status: 'resolved' }); + case 'delete': + return execute('comments.delete', { commentId: params.comment_id }); + default: + throw new Error(`Unknown comment action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/control.ts b/packages/llm-tools/src/router/control.ts new file mode 100644 index 0000000000..6d45ee8b6a --- /dev/null +++ b/packages/llm-tools/src/router/control.ts @@ -0,0 +1,39 @@ +import type { Executor } from '../types.js'; +import { parseTarget } from './utils.js'; + +export async function routeControl(params: Record, execute: Executor) { + const action = params.action as string; + const target = parseTarget(params); + + switch (action) { + case 'list': + return execute('contentControls.list', {}); + case 'get': + return execute('contentControls.get', { target }); + case 'fill': { + // Auto-detect control type and route to the right operation. + const control = (await execute('contentControls.get', { target })) as Record; + const controlType = control?.controlType as string; + + switch (controlType) { + case 'checkbox': + return execute('contentControls.checkbox.setState', { + target, + checked: params.value === true || params.value === 'true', + }); + case 'dropDownList': + case 'comboBox': + return execute('contentControls.choiceList.setSelected', { target, value: params.value }); + case 'date': + return execute('contentControls.date.setValue', { target, value: params.value }); + default: + // Default to text for plainText, richText, and unknown types + return execute('contentControls.text.setValue', { target, value: params.value }); + } + } + case 'wrap': + return execute('contentControls.wrap', { target, kind: params.kind ?? 'block' }); + default: + throw new Error(`Unknown control action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/create.ts b/packages/llm-tools/src/router/create.ts new file mode 100644 index 0000000000..8877fe9cce --- /dev/null +++ b/packages/llm-tools/src/router/create.ts @@ -0,0 +1,88 @@ +import type { Executor } from '../types.js'; +import { parseTarget, trackedOptions } from './utils.js'; + +export async function routeCreate(params: Record, execute: Executor) { + const type = params.type as string; + const options = trackedOptions(params); + const at = parseTarget(params, 'at'); + + switch (type) { + case 'paragraph': { + const input: Record = {}; + if (params.text) input.text = params.text; + if (at) input.at = at; + return execute('create.paragraph', input, options); + } + case 'heading': { + const input: Record = { level: params.level ?? 1 }; + if (params.text) input.text = params.text; + if (at) input.at = at; + return execute('create.heading', input, options); + } + case 'table': { + const input: Record = { + rows: params.rows ?? 2, + columns: params.cols ?? 2, + }; + if (at) input.at = at; + return execute('create.table', input, options); + } + case 'image': { + const input: Record = { src: params.src }; + if (at) input.at = at; + return execute('create.image', input, options); + } + case 'list': { + const kind = (params.kind ?? 'bullet') as string; + const items = params.items as string[] | undefined; + + // Multi-item shorthand: create the first item, then insert the rest + if (Array.isArray(items) && items.length > 0) { + const firstInput: Record = { kind, text: items[0] }; + if (at) firstInput.at = at; + const firstResult = (await execute('create.list', firstInput, options)) as Record; + + let lastItemAddress = firstResult.item as Record; + const allItems = [lastItemAddress]; + + for (let i = 1; i < items.length; i++) { + const insertResult = (await execute('lists.insert', { + target: { kind: 'content', stability: 'stable', nodeId: lastItemAddress?.nodeId }, + position: 'after', + text: items[i], + })) as Record; + lastItemAddress = insertResult.item as Record; + allItems.push(lastItemAddress); + } + + return { success: true, listId: firstResult.listId, items: allItems }; + } + + // Single-item creation + const input: Record = { kind }; + if (params.text) input.text = params.text; + if (at) input.at = at; + return execute('create.list', input, options); + } + case 'section_break': { + const input: Record = {}; + if (at) input.at = at; + return execute('create.sectionBreak', input, options); + } + case 'toc': { + const input: Record = {}; + if (at) input.at = at; + return execute('create.tableOfContents', input, options); + } + case 'content_control': { + const input: Record = { kind: params.kind ?? 'block' }; + if (at) input.at = at; + if (params.control_type) input.controlType = params.control_type; + return execute('create.contentControl', input, options); + } + default: + throw new Error( + `Unknown block type: "${type}". Expected one of: paragraph, heading, table, image, list, section_break, toc, content_control.`, + ); + } +} diff --git a/packages/llm-tools/src/router/edit.ts b/packages/llm-tools/src/router/edit.ts new file mode 100644 index 0000000000..fa63298193 --- /dev/null +++ b/packages/llm-tools/src/router/edit.ts @@ -0,0 +1,19 @@ +import type { Executor } from '../types.js'; +import { parseTarget, trackedOptions } from './utils.js'; + +export async function routeEdit(params: Record, execute: Executor) { + const action = params.action as string; + const target = parseTarget(params); + const options = trackedOptions(params); + + switch (action) { + case 'insert': + return execute('insert', { value: params.text, target }, options); + case 'replace': + return execute('replace', { text: params.text, target }, options); + case 'delete': + return execute('delete', { target }, options); + default: + throw new Error(`Unknown edit action: "${action}". Expected one of: insert, replace, delete.`); + } +} diff --git a/packages/llm-tools/src/router/find.ts b/packages/llm-tools/src/router/find.ts new file mode 100644 index 0000000000..a1974d7ac4 --- /dev/null +++ b/packages/llm-tools/src/router/find.ts @@ -0,0 +1,23 @@ +import type { Executor } from '../types.js'; +import { enrichFindResults } from './utils.js'; + +export async function routeFind(params: Record, execute: Executor) { + const select: Record = {}; + + if (params.pattern) { + select.type = 'text'; + select.pattern = params.pattern; + select.mode = 'contains'; + if (params.type) select.nodeKind = params.type; + } else if (params.type) { + select.type = 'node'; + select.nodeKind = params.type; + } + + const query: Record = { select }; + if (params.limit != null) query.limit = params.limit; + if (params.offset != null) query.offset = params.offset; + + const result = await execute('find', query); + return enrichFindResults(result, params.pattern as string | undefined); +} diff --git a/packages/llm-tools/src/router/format.ts b/packages/llm-tools/src/router/format.ts new file mode 100644 index 0000000000..7e20519932 --- /dev/null +++ b/packages/llm-tools/src/router/format.ts @@ -0,0 +1,60 @@ +import type { Executor } from '../types.js'; +import { parseTarget, trackedOptions, resolveTextTarget } from './utils.js'; + +const INLINE_KEYS = ['bold', 'italic', 'underline', 'strikethrough', 'font', 'size', 'color', 'highlight'] as const; + +function toRunProperties(params: Record): Record { + const props: Record = {}; + if (params.bold != null) props.bold = Boolean(params.bold); + if (params.italic != null) props.italic = Boolean(params.italic); + if (params.underline != null) props.underline = Boolean(params.underline); + if (params.strikethrough != null) props.strikethrough = Boolean(params.strikethrough); + if (params.font != null) props.fontFamily = params.font; + if (params.size != null) props.fontSize = params.size; + if (params.color != null) props.color = params.color; + if (params.highlight != null) props.highlight = params.highlight; + return props; +} + +export async function routeFormat(params: Record, execute: Executor) { + const rawTarget = parseTarget(params); + const target = await resolveTextTarget(rawTarget, execute); + const options = trackedOptions(params); + const results: unknown[] = []; + + // Inline formatting + const hasInline = INLINE_KEYS.some((k) => params[k] != null); + if (hasInline) { + const patch = toRunProperties(params); + results.push(await execute('format.apply', { target, inline: patch }, options)); + } + + // Paragraph alignment + if (params.alignment != null) { + results.push(await execute('format.paragraph.setAlignment', { target, alignment: params.alignment }, options)); + } + + // Paragraph spacing + if (params.line_spacing != null || params.space_before != null || params.space_after != null) { + const input: Record = { target }; + if (params.line_spacing != null) input.line = params.line_spacing; + if (params.space_before != null) input.before = params.space_before; + if (params.space_after != null) input.after = params.space_after; + results.push(await execute('format.paragraph.setSpacing', input, options)); + } + + // Paragraph indentation + if (params.indent_left != null || params.indent_right != null) { + const input: Record = { target }; + if (params.indent_left != null) input.left = params.indent_left; + if (params.indent_right != null) input.right = params.indent_right; + results.push(await execute('format.paragraph.setIndentation', input, options)); + } + + // Named style + if (params.style != null) { + results.push(await execute('styles.paragraph.setStyle', { target, styleId: params.style }, options)); + } + + return results.length === 1 ? results[0] : results; +} diff --git a/packages/llm-tools/src/router/image.ts b/packages/llm-tools/src/router/image.ts new file mode 100644 index 0000000000..00ba217287 --- /dev/null +++ b/packages/llm-tools/src/router/image.ts @@ -0,0 +1,45 @@ +import type { Executor } from '../types.js'; +import { parseTarget } from './utils.js'; + +const WRAP_TYPE_MAP: Record = { + inline: 'Inline', + square: 'Square', + tight: 'Tight', + through: 'Through', + top_and_bottom: 'TopAndBottom', + none: 'None', +}; + +export async function routeImage(params: Record, execute: Executor) { + const action = params.action as string; + + if (action === 'list') { + return execute('images.list', {}); + } + + // Image operations use imageId (string), not a structured address. + // The target from superdoc_find is a JSON string; we parse it and + // extract the node ID if it's an object, or use it directly if it's a string. + const parsed = parseTarget(params); + const imageId = + typeof parsed === 'object' && parsed !== null + ? ((parsed as Record).nodeId ?? (parsed as Record).id) + : parsed; + + switch (action) { + case 'get': + return execute('images.get', { imageId }); + case 'resize': + return execute('images.setSize', { imageId, size: { width: params.width, height: params.height } }); + case 'set_alt_text': + return execute('images.setAltText', { imageId, description: params.alt_text }); + case 'set_wrap': { + const wrapType = WRAP_TYPE_MAP[params.wrap as string] ?? params.wrap; + return execute('images.setWrapType', { imageId, type: wrapType }); + } + case 'delete': + return execute('images.delete', { imageId }); + default: + throw new Error(`Unknown image action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/index.ts b/packages/llm-tools/src/router/index.ts new file mode 100644 index 0000000000..d7b19c7a8f --- /dev/null +++ b/packages/llm-tools/src/router/index.ts @@ -0,0 +1,47 @@ +import type { ToolRouter } from '../types.js'; +import { routeRead } from './read.js'; +import { routeFind } from './find.js'; +import { routeEdit } from './edit.js'; +import { routeCreate } from './create.js'; +import { routeFormat } from './format.js'; +import { routeTable } from './table.js'; +import { routeList } from './list.js'; +import { routeImage } from './image.js'; +import { routeComment } from './comment.js'; +import { routeReview } from './review.js'; +import { routeSection } from './section.js'; +import { routeReference } from './reference.js'; +import { routeControl } from './control.js'; + +export { + routeRead, + routeFind, + routeEdit, + routeCreate, + routeFormat, + routeTable, + routeList, + routeImage, + routeComment, + routeReview, + routeSection, + routeReference, + routeControl, +}; + +/** Map of tool name → router function. */ +export const ROUTERS: Record = { + superdoc_read: routeRead, + superdoc_find: routeFind, + superdoc_edit: routeEdit, + superdoc_create: routeCreate, + superdoc_format: routeFormat, + superdoc_table: routeTable, + superdoc_list: routeList, + superdoc_image: routeImage, + superdoc_comment: routeComment, + superdoc_review: routeReview, + superdoc_section: routeSection, + superdoc_reference: routeReference, + superdoc_control: routeControl, +}; diff --git a/packages/llm-tools/src/router/list.ts b/packages/llm-tools/src/router/list.ts new file mode 100644 index 0000000000..dbcf58289b --- /dev/null +++ b/packages/llm-tools/src/router/list.ts @@ -0,0 +1,25 @@ +import type { Executor } from '../types.js'; +import { parseTarget } from './utils.js'; + +export async function routeList(params: Record, execute: Executor) { + const action = params.action as string; + const target = parseTarget(params); + + switch (action) { + case 'insert': { + const input: Record = { target, position: params.position ?? 'after' }; + if (params.text) input.text = params.text; + return execute('lists.insert', input); + } + case 'indent': + return execute('lists.indent', { target }); + case 'outdent': + return execute('lists.outdent', { target }); + case 'set_type': + return execute('lists.setType', { target, kind: params.kind }); + case 'detach': + return execute('lists.detach', { target }); + default: + throw new Error(`Unknown list action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/read.ts b/packages/llm-tools/src/router/read.ts new file mode 100644 index 0000000000..45a722b6f3 --- /dev/null +++ b/packages/llm-tools/src/router/read.ts @@ -0,0 +1,17 @@ +import type { Executor } from '../types.js'; + +const FORMAT_TO_OPERATION: Record = { + text: 'getText', + markdown: 'getMarkdown', + html: 'getHtml', + info: 'info', +}; + +export async function routeRead(params: Record, execute: Executor) { + const format = params.format as string; + const operationId = FORMAT_TO_OPERATION[format]; + if (!operationId) { + throw new Error(`Unknown format: "${format}". Expected one of: text, markdown, html, info.`); + } + return execute(operationId, {}); +} diff --git a/packages/llm-tools/src/router/reference.ts b/packages/llm-tools/src/router/reference.ts new file mode 100644 index 0000000000..4ad2a63e7a --- /dev/null +++ b/packages/llm-tools/src/router/reference.ts @@ -0,0 +1,56 @@ +import type { Executor } from '../types.js'; +import { parseTarget } from './utils.js'; + +export async function routeReference(params: Record, execute: Executor) { + const action = params.action as string; + const target = parseTarget(params); + + switch (action) { + // Hyperlinks + case 'list_links': + return execute('hyperlinks.list', {}); + case 'insert_link': + return execute('hyperlinks.insert', { + target, + text: params.text, + link: { destination: { href: params.url } }, + }); + case 'update_link': { + const linkTarget = parseTarget(params, 'id'); + const patch: Record = {}; + if (params.url != null) patch.href = params.url; + if (params.tooltip != null) patch.tooltip = params.tooltip; + return execute('hyperlinks.patch', { target: linkTarget, patch }); + } + case 'remove_link': { + const linkTarget = parseTarget(params, 'id'); + return execute('hyperlinks.remove', { target: linkTarget }); + } + // Bookmarks + case 'list_bookmarks': + return execute('bookmarks.list', {}); + case 'insert_bookmark': + if (!target) throw new Error('insert_bookmark requires a "target" address. Use superdoc_find first.'); + return execute('bookmarks.insert', { at: target, name: params.name }); + case 'remove_bookmark': { + const bookmarkTarget = parseTarget(params, 'id'); + return execute('bookmarks.remove', { target: bookmarkTarget }); + } + // Footnotes + case 'list_footnotes': + return execute('footnotes.list', {}); + case 'insert_footnote': + if (!target) throw new Error('insert_footnote requires a "target" address. Use superdoc_find first.'); + return execute('footnotes.insert', { + at: target, + type: params.note_type ?? 'footnote', + content: params.text, + }); + case 'remove_footnote': { + const footnoteTarget = parseTarget(params, 'id'); + return execute('footnotes.remove', { target: footnoteTarget }); + } + default: + throw new Error(`Unknown reference action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/review.ts b/packages/llm-tools/src/router/review.ts new file mode 100644 index 0000000000..4300aa3445 --- /dev/null +++ b/packages/llm-tools/src/router/review.ts @@ -0,0 +1,23 @@ +import type { Executor } from '../types.js'; + +export async function routeReview(params: Record, execute: Executor) { + const action = params.action as string; + + switch (action) { + case 'list': { + const input: Record = {}; + if (params.type) input.type = params.type; + return execute('trackChanges.list', input); + } + case 'accept': + return execute('trackChanges.decide', { decision: 'accept', target: { id: params.id } }); + case 'reject': + return execute('trackChanges.decide', { decision: 'reject', target: { id: params.id } }); + case 'accept_all': + return execute('trackChanges.decide', { decision: 'accept', target: { scope: 'all' } }); + case 'reject_all': + return execute('trackChanges.decide', { decision: 'reject', target: { scope: 'all' } }); + default: + throw new Error(`Unknown review action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/section.ts b/packages/llm-tools/src/router/section.ts new file mode 100644 index 0000000000..a458e4e025 --- /dev/null +++ b/packages/llm-tools/src/router/section.ts @@ -0,0 +1,50 @@ +import type { Executor } from '../types.js'; +import { parseTarget } from './utils.js'; + +const BREAK_TYPE_MAP: Record = { + page: 'nextPage', + continuous: 'continuous', + even: 'evenPage', + odd: 'oddPage', +}; + +export async function routeSection(params: Record, execute: Executor) { + const action = params.action as string; + const target = parseTarget(params); + + switch (action) { + case 'get_layout': + return execute('sections.get', { address: target }); + case 'set_margins': + return execute('sections.setPageMargins', { + target, + top: params.top, + bottom: params.bottom, + left: params.left, + right: params.right, + }); + case 'set_orientation': + return execute('sections.setPageSetup', { target, orientation: params.orientation }); + case 'set_size': + return execute('sections.setPageSetup', { target, width: params.width, height: params.height }); + case 'insert_break': { + const breakType = BREAK_TYPE_MAP[params.break_type as string] ?? params.break_type; + return execute('create.sectionBreak', { breakType }); + } + case 'list_headers_footers': + return execute('headerFooters.list', { section: target, kind: params.kind }); + case 'get_header_footer': { + if (!params.kind) throw new Error('get_header_footer requires "kind" (header or footer).'); + return execute('headerFooters.get', { + target: { + kind: 'headerFooterSlot', + section: target, + headerFooterKind: params.kind, + variant: params.slot ?? 'default', + }, + }); + } + default: + throw new Error(`Unknown section action: "${action}".`); + } +} diff --git a/packages/llm-tools/src/router/table.ts b/packages/llm-tools/src/router/table.ts new file mode 100644 index 0000000000..8b6170531d --- /dev/null +++ b/packages/llm-tools/src/router/table.ts @@ -0,0 +1,83 @@ +import type { Executor } from '../types.js'; +import { parseTarget, extractTableCellParagraphIds } from './utils.js'; + +export async function routeTable(params: Record, execute: Executor) { + const action = params.action as string; + const target = parseTarget(params); + + switch (action) { + case 'get': + return execute('tables.get', { target }); + case 'set_cells': + return setTableCells(target, params.data as unknown[][], execute); + case 'insert_row': + return execute('tables.insertRow', { target, position: params.position }); + case 'delete_row': + return execute('tables.deleteRow', { target }); + case 'insert_column': + return execute('tables.insertColumn', { + tableTarget: target, + columnIndex: params.column_index, + position: params.position, + }); + case 'delete_column': + return execute('tables.deleteColumn', { + tableTarget: target, + columnIndex: params.column_index, + }); + case 'merge_cells': + return execute('tables.mergeCells', { + tableTarget: target, + start: params.start, + end: params.end, + }); + case 'set_style': + return execute('tables.setStyle', { target, styleId: params.style }); + case 'sort': + return execute('tables.sort', { target, keys: params.keys }); + default: + throw new Error( + `Unknown table action: "${action}". Expected one of: get, set_cells, insert_row, delete_row, insert_column, delete_column, merge_cells, set_style, sort.`, + ); + } +} + +/** + * Batch-populate table cells from a 2D data array. + * Resolves the table node, extracts each cell's paragraph ID, + * then inserts text into each cell in a single logical operation. + */ +async function setTableCells(target: unknown, data: unknown[][], execute: Executor) { + if (!Array.isArray(data) || data.length === 0) { + throw new Error('set_cells requires a "data" array of rows, e.g. [["A","B"],["C","D"]].'); + } + + // Resolve the table target to get the node structure + const tgt = target as Record; + const nodeId = tgt?.nodeId ?? tgt?.blockId; + if (!nodeId) throw new Error('set_cells target must include a nodeId (content address of the table).'); + + const nodeResult = (await execute('getNodeById', { nodeId })) as Record; + const node = nodeResult.node as Record; + if (!node) throw new Error('Could not resolve table node.'); + + const cellIds = extractTableCellParagraphIds(node); + if (!cellIds) throw new Error('Target is not a table or has no rows.'); + + let cellsSet = 0; + for (let r = 0; r < data.length && r < cellIds.length; r++) { + const row = data[r] as unknown[]; + if (!Array.isArray(row)) continue; + for (let c = 0; c < row.length && c < cellIds[r].length; c++) { + const text = row[c]; + if (text == null || text === '') continue; + const paragraphId = cellIds[r][c]; + if (!paragraphId) continue; + const cellTarget = { kind: 'text', blockId: paragraphId, range: { start: 0, end: 0 } }; + await execute('insert', { value: String(text), target: cellTarget }); + cellsSet++; + } + } + + return { success: true, cellsSet, rows: Math.min(data.length, cellIds.length), columns: cellIds[0]?.length ?? 0 }; +} diff --git a/packages/llm-tools/src/router/utils.ts b/packages/llm-tools/src/router/utils.ts new file mode 100644 index 0000000000..13ca212b2b --- /dev/null +++ b/packages/llm-tools/src/router/utils.ts @@ -0,0 +1,167 @@ +import type { Executor } from '../types.js'; + +/** + * Parse a JSON string parameter from the LLM input. + * Returns undefined if the param is not set. + * Throws a clear error if the JSON is malformed. + */ +export function parseTarget(params: Record, key = 'target'): unknown { + const raw = params[key]; + if (raw == null) return undefined; + try { + return JSON.parse(raw as string); + } catch { + throw new Error(`Invalid JSON in "${key}" parameter: ${raw}`); + } +} + +/** Returns tracked-change options when suggest mode is enabled. */ +export function trackedOptions(params: Record) { + return params.suggest ? { changeMode: 'tracked' as const } : undefined; +} + +// --------------------------------------------------------------------------- +// Address helpers +// --------------------------------------------------------------------------- + +type Rec = Record; + +function isRec(v: unknown): v is Rec { + return v != null && typeof v === 'object' && !Array.isArray(v); +} + +function isContentAddress(v: unknown): v is Rec & { kind: 'content'; nodeId: string } { + return isRec(v) && v.kind === 'content' && typeof v.nodeId === 'string'; +} + +function isTextAddress(v: unknown): v is Rec & { kind: 'text'; blockId: string; range: Rec } { + return isRec(v) && v.kind === 'text' && typeof v.blockId === 'string' && isRec(v.range); +} + +/** Compute total text length from a node's inline runs (paragraph, heading, list-item content). */ +function computeInlinesTextLength(inlines: unknown[]): number { + let len = 0; + for (const inline of inlines) { + if (!isRec(inline)) continue; + if (inline.kind === 'run' && isRec(inline.run) && typeof inline.run.text === 'string') { + len += inline.run.text.length; + } else if (inline.kind === 'hyperlink' && isRec(inline.hyperlink) && Array.isArray(inline.hyperlink.inlines)) { + len += computeInlinesTextLength(inline.hyperlink.inlines); + } else if (inline.kind === 'tab' || inline.kind === 'lineBreak') { + len += 1; + } + } + return len; +} + +/** Extract inlines array from an SDContentNode (paragraph, heading). */ +function extractInlines(node: Rec): unknown[] | null { + if (node.kind === 'paragraph' && isRec(node.paragraph) && Array.isArray(node.paragraph.inlines)) { + return node.paragraph.inlines; + } + if (node.kind === 'heading' && isRec(node.heading) && Array.isArray(node.heading.inlines)) { + return node.heading.inlines; + } + return null; +} + +/** + * Resolve a target to a TextAddress if it's a content address. + * + * Content addresses (`kind: 'content'`) come from `find` and `create` results. + * Operations like format.apply and comments.create require text addresses. + * This helper bridges the gap by looking up the node and computing a text range. + */ +export async function resolveTextTarget(target: unknown, execute: Executor): Promise { + if (!target || isTextAddress(target)) return target; + if (!isContentAddress(target)) return target; + + const result = (await execute('getNodeById', { nodeId: target.nodeId })) as Rec; + const node = result.node as Rec; + if (!node) return target; + + const inlines = extractInlines(node); + if (!inlines) return target; + + const textLength = computeInlinesTextLength(inlines); + return { kind: 'text', blockId: target.nodeId, range: { start: 0, end: textLength } }; +} + +/** + * Enrich find results with `textAddress` fields so the model can use them + * directly for format/comment/edit operations without manual address conversion. + * + * For text searches, also computes a `matchAddress` with the exact range of the + * first occurrence of the search pattern within the node's text. + */ +export function enrichFindResults(result: unknown, pattern?: string): unknown { + if (!isRec(result) || !Array.isArray(result.items)) return result; + + const enrichedItems = result.items.map((item: unknown) => { + if (!isRec(item) || !isRec(item.node) || !isRec(item.address)) return item; + + const node = item.node as Rec; + const nodeId = node.id as string; + if (!nodeId) return item; + + const inlines = extractInlines(node); + if (!inlines) return item; + + const textLength = computeInlinesTextLength(inlines); + const textAddress = { kind: 'text', blockId: nodeId, range: { start: 0, end: textLength } }; + + const enriched: Rec = { ...item, textAddress }; + + // For text searches, find the match offset within the node's flattened text + if (pattern) { + const flatText = flattenInlinesText(inlines); + const idx = flatText.toLowerCase().indexOf(pattern.toLowerCase()); + if (idx >= 0) { + enriched.matchAddress = { + kind: 'text', + blockId: nodeId, + range: { start: idx, end: idx + pattern.length }, + }; + } + } + + return enriched; + }); + + return { ...result, items: enrichedItems }; +} + +/** Flatten all inline run text into a single string. */ +function flattenInlinesText(inlines: unknown[]): string { + let text = ''; + for (const inline of inlines) { + if (!isRec(inline)) continue; + if (inline.kind === 'run' && isRec(inline.run) && typeof inline.run.text === 'string') { + text += inline.run.text; + } else if (inline.kind === 'hyperlink' && isRec(inline.hyperlink) && Array.isArray(inline.hyperlink.inlines)) { + text += flattenInlinesText(inline.hyperlink.inlines); + } else if (inline.kind === 'tab' || inline.kind === 'lineBreak') { + text += '\t'; // placeholder char so offsets stay correct + } + } + return text; +} + +/** + * Extract cell paragraph IDs from a table SDNodeResult. + * Returns a 2D array: `paragraphIds[row][col]` = first paragraph ID in each cell. + */ +export function extractTableCellParagraphIds(node: Rec): string[][] | null { + if (node.kind !== 'table' || !isRec(node.table) || !Array.isArray(node.table.rows)) return null; + + return node.table.rows.map((row: unknown) => { + if (!isRec(row) || !Array.isArray(row.cells)) return []; + return row.cells.map((cell: unknown) => { + if (!isRec(cell) || !Array.isArray(cell.content)) return ''; + const firstParagraph = cell.content.find( + (c: unknown) => isRec(c) && (c.kind === 'paragraph' || c.kind === 'heading'), + ) as Rec | undefined; + return (firstParagraph?.id as string) ?? ''; + }); + }); +} diff --git a/packages/llm-tools/src/types.ts b/packages/llm-tools/src/types.ts new file mode 100644 index 0000000000..0a5ef295cc --- /dev/null +++ b/packages/llm-tools/src/types.ts @@ -0,0 +1,32 @@ +/** A JSON Schema object (draft 2020-12 compatible). */ +export type JsonSchema = Record; + +/** MCP-style tool annotations. */ +export interface ToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +/** A single LLM tool definition — what the model sees. */ +export interface ToolDefinition { + name: string; + description: string; + inputSchema: JsonSchema; + annotations?: ToolAnnotations; +} + +/** + * Transport-agnostic executor. + * MCP: `(opId, input, opts) => api.invoke({ operationId, input, options })` + * SDK: routes through CLI transport. + */ +export type Executor = ( + operationId: string, + input: Record, + options?: Record, +) => Promise; + +/** A router function that dispatches tool params through an executor. */ +export type ToolRouter = (params: Record, execute: Executor) => Promise; diff --git a/packages/llm-tools/tsconfig.json b/packages/llm-tools/tsconfig.json new file mode 100644 index 0000000000..878e7d1a74 --- /dev/null +++ b/packages/llm-tools/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12b1a2f414..ad16b9d06a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,6 +515,9 @@ importers: '@superdoc/document-api': specifier: workspace:* version: link:../../packages/document-api + '@superdoc/llm-tools': + specifier: workspace:* + version: link:../../packages/llm-tools '@superdoc/super-editor': specifier: workspace:* version: link:../../packages/super-editor @@ -947,6 +950,12 @@ importers: specifier: 'catalog:' version: 4.21.0 + packages/llm-tools: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/preset-geometry: {} packages/react: