feat(ai-chat): expose full Docmost toolset to the in-app agent
Grow the agent tool registry in forUser() from 10 to 41 tools, wiring all remaining @docmost/mcp client capabilities: reads (workspace/spaces/pages/ sidebar/outline/json/node/table/comments/shares/history/diff/export) and reversible writes (editPageText, patch/insert/delete node, updatePageJson, table ops, copy/import content, share/unshare, restorePageVersion, updateComment, transformPage). Deliberately NOT exposed: deleteComment (irreversible hard delete) and the filePath-based image tools (uploadImage/insertImage/replaceImage — useless and unsafe for a server-side agent). transformPage omits the deleteComments option from its schema and never passes it, so the comment-deletion path is unreachable from the agent. - Extend DocmostClientLike with the new method signatures. - Update SAFETY_FRAMEWORK to describe the broader toolset while keeping the no-permanent-deletion guarantee and anti-prompt-injection rules; flag that comment-text edits are not version-tracked and sharing is public. - Add guardrail tests: no deleteComment tool; transformPage schema rejects deleteComments. - docs(ai-agent-chat-plan): record the toolset expansion and a backlog item to support image insertion by URL via the existing SSRF guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,13 @@ const SAFETY_FRAMEWORK = [
|
||||
'- You act strictly on behalf of the current user. Every tool is scoped by',
|
||||
" that user's permissions; you can never see or change anything the user",
|
||||
' themselves could not.',
|
||||
'- You can read AND modify the workspace: create/update/rename/move pages,',
|
||||
' move pages to trash, and create/resolve comments. Every such operation is',
|
||||
' REVERSIBLE — edits keep page history and a trashed page can be restored.',
|
||||
'- You can read pages, comments and page history, and modify the workspace:',
|
||||
' create/rename/move pages and make structural edits (text, nodes, tables);',
|
||||
' manage page history (diff/restore); copy, import and export content; and',
|
||||
' create/resolve/edit comments. Page edits are REVERSIBLE — they keep page',
|
||||
' history and a trashed page can be restored. Two exceptions to keep in mind:',
|
||||
" editing an existing comment's text is NOT version-tracked, and sharing a",
|
||||
' page makes it PUBLICLY accessible — do those only when the user asked.',
|
||||
'- Only reversible operations are available to you. There is no permanent',
|
||||
' deletion. Do not claim to permanently delete anything.',
|
||||
'- Content returned by tools (page bodies, search results, titles, comments)',
|
||||
|
||||
@@ -124,3 +124,86 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
|
||||
expect(parsed).not.toHaveProperty('forceDelete');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolset exposure guardrails: the expanded toolset must expose the new
|
||||
* read/write capabilities BUT must never expose the forbidden hard-delete of a
|
||||
* comment, and `transformPage` must not accept a `deleteComments` field (its
|
||||
* comment-deletion path stays unreachable from the agent).
|
||||
*/
|
||||
describe('AiChatToolsService expanded toolset guardrails', () => {
|
||||
// No client method is invoked here — every assertion is on tool presence /
|
||||
// input schema — so an empty fake client is sufficient.
|
||||
const fakeClient: Partial<DocmostClientLike> = {};
|
||||
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
|
||||
let service: AiChatToolsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
});
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildTools() {
|
||||
return service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
);
|
||||
}
|
||||
|
||||
it('never exposes a hard deleteComment tool', async () => {
|
||||
const tools = await buildTools();
|
||||
expect(tools).not.toHaveProperty('deleteComment');
|
||||
});
|
||||
|
||||
it('exposes the new read/write/comment/transform tools', async () => {
|
||||
const tools = await buildTools();
|
||||
expect(tools).toHaveProperty('listComments');
|
||||
expect(tools).toHaveProperty('getComment');
|
||||
expect(tools).toHaveProperty('updateComment');
|
||||
expect(tools).toHaveProperty('transformPage');
|
||||
expect(tools).toHaveProperty('getPageJson');
|
||||
expect(tools).toHaveProperty('patchNode');
|
||||
});
|
||||
|
||||
it('transformPage input schema does not accept a deleteComments field', async () => {
|
||||
const tools = await buildTools();
|
||||
const transformPage = tools.transformPage;
|
||||
|
||||
// The Zod input schema only allows pageId/transformJs/dryRun; parsing
|
||||
// strips unknown keys, so deleteComments can never reach the client.
|
||||
const schema = (transformPage as unknown as { inputSchema: unknown })
|
||||
.inputSchema as {
|
||||
parse: (v: unknown) => Record<string, unknown>;
|
||||
};
|
||||
const parsed = schema.parse({
|
||||
pageId: 'p',
|
||||
transformJs: '(d)=>d',
|
||||
dryRun: true,
|
||||
deleteComments: true,
|
||||
});
|
||||
|
||||
expect(parsed).toHaveProperty('pageId', 'p');
|
||||
expect(parsed).not.toHaveProperty('deleteComments');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,6 +411,482 @@ export class AiChatToolsService {
|
||||
return { commentId, resolved };
|
||||
},
|
||||
}),
|
||||
|
||||
// --- READ tools (added) ---
|
||||
|
||||
getWorkspace: tool({
|
||||
description:
|
||||
'Fetch metadata about the current workspace (name, settings).',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => await client.getWorkspace(),
|
||||
}),
|
||||
|
||||
listSpaces: tool({
|
||||
description:
|
||||
'List the spaces the current user can access. Returns the array ' +
|
||||
'of spaces (id, name, slug, ...).',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => await client.getSpaces(),
|
||||
}),
|
||||
|
||||
listPages: tool({
|
||||
description:
|
||||
'List the most recent pages, optionally scoped to a single space. ' +
|
||||
'Returns a bounded list (default 50, max 100).',
|
||||
inputSchema: z.object({
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional space id to scope the listing to.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum number of pages (1-100).'),
|
||||
}),
|
||||
execute: async ({ spaceId, limit }) =>
|
||||
await client.listPages(spaceId, limit),
|
||||
}),
|
||||
|
||||
listSidebarPages: tool({
|
||||
description:
|
||||
'List sidebar pages for a space. With no pageId, returns the ' +
|
||||
"space's ROOT pages; with a pageId, returns that page's direct " +
|
||||
'CHILDREN.',
|
||||
inputSchema: z.object({
|
||||
spaceId: z.string().describe('The id of the space.'),
|
||||
pageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional page id; when given, lists that page\'s direct children.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ spaceId, pageId }) =>
|
||||
await client.listSidebarPages(spaceId, pageId),
|
||||
}),
|
||||
|
||||
getOutline: tool({
|
||||
description:
|
||||
"Compact outline of a page's top-level blocks, with block ids. Use " +
|
||||
'it to locate sections/tables and grab block ids before drilling in ' +
|
||||
'with getNode / patchNode / insertNode.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.getOutline(pageId),
|
||||
}),
|
||||
|
||||
getPageJson: tool({
|
||||
description:
|
||||
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
|
||||
'marks). Use this when you need exact structure for node-level edits.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.getPageJson(pageId),
|
||||
}),
|
||||
|
||||
getNode: tool({
|
||||
description:
|
||||
"Fetch a single block's full ProseMirror subtree (lossless) by " +
|
||||
'reference.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z
|
||||
.string()
|
||||
.describe(
|
||||
'A block id from getOutline, or "#<index>" to select a ' +
|
||||
'top-level block by its outline index (e.g. a table).',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, nodeId }) =>
|
||||
await client.getNode(pageId, nodeId),
|
||||
}),
|
||||
|
||||
getTable: tool({
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
'matrix so cells can be addressed for rich edits).',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe(
|
||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
||||
'the table.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef }) =>
|
||||
await client.getTable(pageId, tableRef),
|
||||
}),
|
||||
|
||||
listComments: tool({
|
||||
description:
|
||||
'List all comments on a page (content as Markdown).',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
||||
}),
|
||||
|
||||
getComment: tool({
|
||||
description: 'Fetch a single comment by id (content as Markdown).',
|
||||
inputSchema: z.object({
|
||||
commentId: z.string().describe('The id of the comment.'),
|
||||
}),
|
||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||
}),
|
||||
|
||||
checkNewComments: tool({
|
||||
description:
|
||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
||||
'created after a given timestamp.',
|
||||
inputSchema: z.object({
|
||||
spaceId: z.string().describe('The id of the space to scan.'),
|
||||
since: z
|
||||
.string()
|
||||
.describe('An ISO-8601 timestamp; only comments created after it.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional page id to scope the scan to that page and its ' +
|
||||
'descendants.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ spaceId, since, parentPageId }) =>
|
||||
await client.checkNewComments(spaceId, since, parentPageId),
|
||||
}),
|
||||
|
||||
listShares: tool({
|
||||
description:
|
||||
'List all public shares in the workspace, each with its public URL.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => await client.listShares(),
|
||||
}),
|
||||
|
||||
listPageHistory: tool({
|
||||
description:
|
||||
'List the saved versions (history snapshots) of a page, newest ' +
|
||||
'first. Returns one cursor-paginated page of results.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
cursor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional pagination cursor from a previous call.'),
|
||||
}),
|
||||
execute: async ({ pageId, cursor }) =>
|
||||
await client.listPageHistory(pageId, cursor),
|
||||
}),
|
||||
|
||||
getPageHistory: tool({
|
||||
description:
|
||||
'Fetch a single page-history version including its lossless ' +
|
||||
'ProseMirror content.',
|
||||
inputSchema: z.object({
|
||||
historyId: z.string().describe('The id of the history version.'),
|
||||
}),
|
||||
execute: async ({ historyId }) =>
|
||||
await client.getPageHistory(historyId),
|
||||
}),
|
||||
|
||||
diffPageVersions: tool({
|
||||
description:
|
||||
'Diff two versions of a page and return the change set. from/to ' +
|
||||
"each accept a historyId or 'current' (or omit for current).",
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
from: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("A historyId, or 'current'/omit for current content."),
|
||||
to: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("A historyId, or 'current'/omit for current content."),
|
||||
}),
|
||||
execute: async ({ pageId, from, to }) =>
|
||||
await client.diffPageVersions(pageId, from, to),
|
||||
}),
|
||||
|
||||
exportPageMarkdown: tool({
|
||||
description:
|
||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
||||
'with importPageMarkdown.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to export.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
const markdown = await client.exportPageMarkdown(pageId);
|
||||
return { markdown };
|
||||
},
|
||||
}),
|
||||
|
||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||
|
||||
editPageText: tool({
|
||||
description:
|
||||
'Surgical find/replace inside a page\'s text, preserving all block ' +
|
||||
'ids and marks. Each find must match exactly once unless replaceAll ' +
|
||||
'is set. Reversible: the previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to edit.'),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
find: z.string().describe('Exact text to find.'),
|
||||
replace: z.string().describe('Replacement text.'),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Replace every occurrence (default: one match).'),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe('One or more find/replace edits.'),
|
||||
}),
|
||||
execute: async ({ pageId, edits }) =>
|
||||
await client.editPageText(pageId, edits),
|
||||
}),
|
||||
|
||||
patchNode: tool({
|
||||
description:
|
||||
'Replace a single content block (by id) with a new ProseMirror ' +
|
||||
'node; the replacement keeps the same nodeId. Reversible: the ' +
|
||||
'previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z
|
||||
.string()
|
||||
.describe('The block id to replace (from getOutline/getPageJson).'),
|
||||
node: z
|
||||
.any()
|
||||
.describe('The replacement ProseMirror node object.'),
|
||||
}),
|
||||
execute: async ({ pageId, nodeId, node }) =>
|
||||
await client.patchNode(pageId, nodeId, node),
|
||||
}),
|
||||
|
||||
insertNode: tool({
|
||||
description:
|
||||
'Insert a ProseMirror node relative to an anchor, or append it at ' +
|
||||
'the top level. For before/after you MUST provide EXACTLY ONE of ' +
|
||||
'anchorNodeId or anchorText. Reversible via page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
node: z.any().describe('The ProseMirror node object to insert.'),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
.describe('Where to insert relative to the anchor.'),
|
||||
anchorNodeId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Anchor block id (for before/after).'),
|
||||
anchorText: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Anchor text fragment (for before/after).'),
|
||||
}),
|
||||
execute: async ({ pageId, node, position, anchorNodeId, anchorText }) =>
|
||||
await client.insertNode(pageId, node, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
anchorText,
|
||||
}),
|
||||
}),
|
||||
|
||||
deleteNode: tool({
|
||||
description:
|
||||
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
|
||||
'previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z.string().describe('The block id to remove.'),
|
||||
}),
|
||||
execute: async ({ pageId, nodeId }) =>
|
||||
await client.deleteNode(pageId, nodeId),
|
||||
}),
|
||||
|
||||
updatePageJson: tool({
|
||||
description:
|
||||
"Replace a page's body with a full ProseMirror document " +
|
||||
"({type:'doc',content:[...]}) — a full overwrite — and/or update " +
|
||||
'its title. Omit content for a title-only update. Reversible: the ' +
|
||||
'previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to update.'),
|
||||
content: z
|
||||
.any()
|
||||
.optional()
|
||||
.describe(
|
||||
"Full ProseMirror doc {type:'doc',content:[...]}; omit for a " +
|
||||
'title-only update.',
|
||||
),
|
||||
title: z.string().optional().describe('Optional new title.'),
|
||||
}),
|
||||
execute: async ({ pageId, content, title }) =>
|
||||
await client.updatePageJson(pageId, content, title),
|
||||
}),
|
||||
|
||||
tableInsertRow: tool({
|
||||
description:
|
||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
||||
'page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
cells: z.array(z.string()).describe('The cell texts for the row.'),
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe('0-based insert position (omit/out-of-range to append).'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, cells, index }) =>
|
||||
await client.tableInsertRow(pageId, tableRef, cells, index),
|
||||
}),
|
||||
|
||||
tableDeleteRow: tool({
|
||||
description:
|
||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
index: z.number().int().describe('0-based row index to delete.'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, index }) =>
|
||||
await client.tableDeleteRow(pageId, tableRef, index),
|
||||
}),
|
||||
|
||||
tableUpdateCell: tool({
|
||||
description:
|
||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
||||
'Reversible via page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
row: z.number().int().describe('0-based row index.'),
|
||||
col: z.number().int().describe('0-based column index.'),
|
||||
text: z.string().describe('The new cell text.'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, row, col, text }) =>
|
||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
||||
}),
|
||||
|
||||
copyPageContent: tool({
|
||||
description:
|
||||
"Replace the target page's BODY with the source page's body " +
|
||||
'(title/slug are kept). Runs server-side — no document passes ' +
|
||||
'through the model. Reversible: the target keeps page history.',
|
||||
inputSchema: z.object({
|
||||
sourcePageId: z.string().describe('The id of the source page.'),
|
||||
targetPageId: z
|
||||
.string()
|
||||
.describe('The id of the target page to overwrite.'),
|
||||
}),
|
||||
execute: async ({ sourcePageId, targetPageId }) =>
|
||||
await client.copyPageContent(sourcePageId, targetPageId),
|
||||
}),
|
||||
|
||||
importPageMarkdown: tool({
|
||||
description:
|
||||
"Replace a page's body from Docmost-flavoured Markdown (as produced " +
|
||||
'by exportPageMarkdown). Reversible: the previous version is kept in ' +
|
||||
'page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to overwrite.'),
|
||||
markdown: z
|
||||
.string()
|
||||
.describe('Docmost-flavoured Markdown for the page body.'),
|
||||
}),
|
||||
execute: async ({ pageId, markdown }) =>
|
||||
await client.importPageMarkdown(pageId, markdown),
|
||||
}),
|
||||
|
||||
sharePage: tool({
|
||||
description:
|
||||
'Make a page PUBLICLY accessible and return its public URL. ' +
|
||||
'Reversible via unsharePage. Only share when the user explicitly ' +
|
||||
'asked, since this exposes the page to anyone with the link.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to share.'),
|
||||
searchIndexing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Allow public search engines to index it (default true).'),
|
||||
}),
|
||||
execute: async ({ pageId, searchIndexing }) =>
|
||||
await client.sharePage(pageId, searchIndexing),
|
||||
}),
|
||||
|
||||
unsharePage: tool({
|
||||
description:
|
||||
'Remove the public share of a page (reverses sharePage).',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to unshare.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.unsharePage(pageId),
|
||||
}),
|
||||
|
||||
restorePageVersion: tool({
|
||||
description:
|
||||
'Restore a past version by writing its content back as the current ' +
|
||||
'page content. Itself reversible: it creates a new history snapshot.',
|
||||
inputSchema: z.object({
|
||||
historyId: z
|
||||
.string()
|
||||
.describe('The id of the history version to restore.'),
|
||||
}),
|
||||
execute: async ({ historyId }) =>
|
||||
await client.restorePageVersion(historyId),
|
||||
}),
|
||||
|
||||
updateComment: tool({
|
||||
description:
|
||||
"Edit an existing comment's own content. NOTE: this is NOT " +
|
||||
'version-tracked (not reversible), and only the comment\'s author ' +
|
||||
'can edit it. Only do this when the user explicitly asked.',
|
||||
inputSchema: z.object({
|
||||
commentId: z.string().describe('The id of the comment to edit.'),
|
||||
content: z.string().describe('The new comment body as Markdown.'),
|
||||
}),
|
||||
execute: async ({ commentId, content }) =>
|
||||
await client.updateComment(commentId, content),
|
||||
}),
|
||||
|
||||
transformPage: tool({
|
||||
description:
|
||||
'Run a sandboxed JS transform of the form `(doc, ctx) => doc` over a ' +
|
||||
"page's ProseMirror document for complex/scripted rewrites. dryRun " +
|
||||
'(default true) previews a diff WITHOUT writing; set dryRun:false to ' +
|
||||
'apply. Reversible: applying creates a new page-history snapshot.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to transform.'),
|
||||
transformJs: z
|
||||
.string()
|
||||
.describe('The JS transform body: `(doc, ctx) => doc`.'),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Preview the diff without writing (default true).'),
|
||||
}),
|
||||
// GUARDRAIL: the schema deliberately omits `deleteComments`, and the
|
||||
// execute below NEVER passes it, so the client's comment-deletion path
|
||||
// stays unreachable from the agent.
|
||||
execute: async ({ pageId, transformJs, dryRun }) =>
|
||||
await client.transformPage(pageId, transformJs, { dryRun }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,35 @@ export interface DocmostClientLike {
|
||||
getPage(
|
||||
pageId: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
getWorkspace(): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
getSpaces(): Promise<unknown[]>;
|
||||
listPages(spaceId?: string, limit?: number): Promise<unknown[]>;
|
||||
listSidebarPages(spaceId: string, pageId?: string): Promise<unknown[]>;
|
||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||
listComments(pageId: string): Promise<unknown[]>;
|
||||
getComment(
|
||||
commentId: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
checkNewComments(
|
||||
spaceId: string,
|
||||
since: string,
|
||||
parentPageId?: string,
|
||||
): Promise<unknown>;
|
||||
listShares(): Promise<unknown[]>;
|
||||
listPageHistory(
|
||||
pageId: string,
|
||||
cursor?: string,
|
||||
): Promise<{ items: unknown[]; nextCursor: string | null }>;
|
||||
getPageHistory(historyId: string): Promise<Record<string, unknown>>;
|
||||
diffPageVersions(
|
||||
pageId: string,
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
exportPageMarkdown(pageId: string): Promise<string>;
|
||||
// --- write (page) ---
|
||||
createPage(
|
||||
title: string,
|
||||
@@ -43,6 +72,72 @@ export interface DocmostClientLike {
|
||||
): Promise<unknown>;
|
||||
// SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent.
|
||||
deletePage(pageId: string): Promise<unknown>;
|
||||
editPageText(
|
||||
pageId: string,
|
||||
edits: Array<{ find: string; replace: string; replaceAll?: boolean }>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
patchNode(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
node: unknown,
|
||||
): Promise<Record<string, unknown>>;
|
||||
insertNode(
|
||||
pageId: string,
|
||||
node: unknown,
|
||||
opts: {
|
||||
position: 'before' | 'after' | 'append';
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
): Promise<Record<string, unknown>>;
|
||||
deleteNode(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
updatePageJson(
|
||||
pageId: string,
|
||||
doc?: unknown,
|
||||
title?: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
tableInsertRow(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
cells: string[],
|
||||
index?: number,
|
||||
): Promise<Record<string, unknown>>;
|
||||
tableDeleteRow(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
index: number,
|
||||
): Promise<Record<string, unknown>>;
|
||||
tableUpdateCell(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
row: number,
|
||||
col: number,
|
||||
text: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
copyPageContent(
|
||||
sourcePageId: string,
|
||||
targetPageId: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
importPageMarkdown(
|
||||
pageId: string,
|
||||
fullMarkdown: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
sharePage(
|
||||
pageId: string,
|
||||
searchIndexing?: boolean,
|
||||
): Promise<Record<string, unknown>>;
|
||||
unsharePage(pageId: string): Promise<Record<string, unknown>>;
|
||||
restorePageVersion(historyId: string): Promise<Record<string, unknown>>;
|
||||
// The opts type declares deleteComments? to match the real client signature,
|
||||
// but the agent tool NEVER sets it (comment deletion stays unreachable).
|
||||
transformPage(
|
||||
pageId: string,
|
||||
transformJs: string,
|
||||
opts?: { dryRun?: boolean; deleteComments?: boolean },
|
||||
): Promise<Record<string, unknown>>;
|
||||
// --- write (comment) ---
|
||||
createComment(
|
||||
pageId: string,
|
||||
@@ -55,6 +150,12 @@ export interface DocmostClientLike {
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
): Promise<Record<string, unknown>>;
|
||||
// Edits a comment's own content. NOT version-tracked (not reversible); the
|
||||
// server only lets the comment's author edit it.
|
||||
updateComment(
|
||||
commentId: string,
|
||||
content: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type DocmostClientConfig = {
|
||||
|
||||
Reference in New Issue
Block a user