import { randomUUID } from "node:crypto"; import { z } from "zod"; import { addDocumentConversation, deleteDocument, findInDocument, findRecentEmptyDocumentByTitle, getDocumentById, getDocumentsForConversation, isDocumentAssociatedWithConversation, replaceInDocument, saveDocument, searchDocumentsByTitle, updateDocumentContent, } from "../../documents/document-store.js"; import { canActOnPrivilegedDocuments } from "../../runtime/effective-capabilities.js"; import { invalidToolInputResult, nullAsOmitted, } from "../shared/zod-tool-schema.js"; import type { ToolContext, ToolExecutionResult } from "../types.js"; function isPrivilegedDocumentActor(context: ToolContext): boolean { return canActOnPrivilegedDocuments(context); } export function documentNotFound(surfaceId: string): ToolExecutionResult { return { content: JSON.stringify({ success: false, surface_id: surfaceId, error: "Document not found", }), isError: true, }; } export function canAccessDocument( surfaceId: string, context: ToolContext, ): boolean { return ( isPrivilegedDocumentActor(context) || isDocumentAssociatedWithConversation(surfaceId, context.conversationId) ); } function invalidInput(message: string): ToolExecutionResult { return { content: JSON.stringify({ success: false, error: `Invalid input: ${message}`, }), isError: true, }; } function validateSurfaceId( input: Record, ): ToolExecutionResult | string { if (typeof input.surface_id !== "string" || input.surface_id.trim() === "") { return invalidInput( "surface_id is required and must be a non-empty string", ); } return input.surface_id; } // ── Model-input schemas ───────────────────────────────────────────── // // One schema per tool entry point, `safeParse`d at the top of each // `execute*`. Skill-owned tools are skipped by the central // `TOOL_INPUT_SCHEMAS` gate, so validation lives in the executor // (`ask_question` pre-registry precedent); the advertised `input_schema` // stays hand-written in the document-editor skill's `TOOLS.json`, and // `document-tool-input-schemas.test.ts` guards the two against structural // drift. // // Tolerance matches the executors' own reads — the schemas only reject // values the executors would otherwise pass downstream unchecked // (`document_find`'s `query` reaching `.toLowerCase()` as a non-string) or // persist unvalidated (`document_create`'s `title`): // // - `nullAsOmitted` on optionals where null and absent are equivalent under // the falsy/`??` reads — including `surface_id`, whose bespoke // `validateSurfaceId` / `resolveUpdateSurfaceId` error messages stay the // contract for the missing/empty cases. // - `.catch(undefined)` on `document_list`'s `query`, which the executor // silently ignores when malformed (falls back to the conversation list). // - `document_update`'s `mode` is deliberately UNDECLARED (loose // passthrough): its bespoke check owns the error message and the // `{ mode: null }` tolerance that mirrors `validateInputAgainstSchema`. export const documentOpenInputSchema = z.looseObject({ surface_id: nullAsOmitted(z.string()), }); export const documentCreateInputSchema = z.looseObject({ title: nullAsOmitted(z.string()), initial_content: nullAsOmitted(z.string()), }); export const documentUpdateInputSchema = z.looseObject({ surface_id: nullAsOmitted(z.string()), content: nullAsOmitted(z.string()), }); export const documentReadInputSchema = documentOpenInputSchema; export const documentListInputSchema = z.looseObject({ query: z.string().optional().catch(undefined), }); export const documentDeleteInputSchema = documentOpenInputSchema; export const documentFindInputSchema = z.looseObject({ surface_id: nullAsOmitted(z.string()), query: z.string(), regex: nullAsOmitted(z.boolean()), case_sensitive: nullAsOmitted(z.boolean()), }); export const documentReplaceTextInputSchema = z.looseObject({ surface_id: nullAsOmitted(z.string()), find: z.string(), replace: nullAsOmitted(z.string()), regex: nullAsOmitted(z.boolean()), case_sensitive: nullAsOmitted(z.boolean()), max_replacements: nullAsOmitted(z.number()), }); // ── Exported execute functions ────────────────────────────────────── export function executeDocumentOpen( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentOpenInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_open", parsedInput.error); } const surfaceIdOrError = validateSurfaceId(input); if (typeof surfaceIdOrError !== "string") { return surfaceIdOrError; } const surfaceId = surfaceIdOrError; if (!canAccessDocument(surfaceId, context)) { return documentNotFound(surfaceId); } const doc = getDocumentById(surfaceId); if (!doc) { return documentNotFound(surfaceId); } if (context.sendToClient) { context.sendToClient({ type: "document_editor_show", conversationId: context.conversationId, surfaceId: doc.surfaceId, title: doc.title, initialContent: doc.content, }); context.sendToClient({ type: "ui_surface_show", conversationId: context.conversationId, surfaceId: `preview-${doc.surfaceId}`, surfaceType: "document_preview", display: "inline", title: doc.title, data: { title: doc.title, surfaceId: doc.surfaceId, subtitle: "Document", }, }); return { content: JSON.stringify({ success: true, surface_id: doc.surfaceId, title: doc.title, word_count: doc.wordCount, message: "Document editor opened", }), isError: false, }; } return { content: JSON.stringify({ success: false, surface_id: surfaceId, error: "No client connected to open document editor", }), isError: true, }; } const EMPTY_DOCUMENT_DEDUPE_WINDOW_MS = 5 * 60 * 1000; /** * If the model just created an empty same-title document in this conversation * and is now creating a second one with real content, reuse the first row * instead of producing a duplicate. Returns `null` when no dedupe applies. * * Only triggers when `initialContent` is non-empty — an empty incoming create * likely means the model intends a fresh blank doc. */ function maybeReuseEmptyDocument( title: string, initialContent: string, context: ToolContext, ): ToolExecutionResult | null { if (initialContent.length === 0) { return null; } const existing = findRecentEmptyDocumentByTitle( context.conversationId, title, EMPTY_DOCUMENT_DEDUPE_WINDOW_MS, ); if (!existing) { return null; } const surfaceId = existing.surfaceId; const update = updateDocumentContent(surfaceId, initialContent, "replace"); if (!update.success) { return null; } // Defensive idempotent insert (saveDocument from the create-new path already // ran addDocumentConversation; INSERT OR IGNORE makes this a safe no-op). addDocumentConversation(surfaceId, context.conversationId); if (context.sendToClient) { // Use document_editor_update — not document_editor_show — because the // empty draft is typically still OPEN on the macOS client. A *_show on an // open doc triggers DocumentManager.closeDocument() → async save() of the // OLD (empty) content, clobbering the initialContent we just persisted. context.sendToClient({ type: "document_editor_update", conversationId: context.conversationId, surfaceId, markdown: initialContent, mode: "replace", }); context.sendToClient({ type: "ui_surface_show", conversationId: context.conversationId, surfaceId: `preview-${surfaceId}`, surfaceType: "document_preview", display: "inline", title, data: { title, surfaceId, subtitle: "Document", }, }); } return { content: JSON.stringify({ surface_id: surfaceId, title, opened: context.sendToClient != null, reused: true, message: "Document editor reopened (deduped empty draft)", }), isError: false, }; } export function executeDocumentCreate( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentCreateInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_create", parsedInput.error); } const title = parsedInput.data.title || "Untitled Document"; const initialContent = parsedInput.data.initial_content || ""; const reused = maybeReuseEmptyDocument(title, initialContent, context); if (reused) { return reused; } const surfaceId = `doc-${randomUUID()}`; // Persist the document so any client (web or macOS) can fetch it via // GET /v1/documents/:id. The macOS client may later update the row // via document_save; ON CONFLICT DO UPDATE handles that. const wordCount = initialContent .split(/\s+/) .filter((w) => w.length > 0).length; saveDocument({ surfaceId, conversationId: context.conversationId, title, content: initialContent, wordCount, }); // Send document_editor_show message to open the built-in RTE if (context.sendToClient) { context.sendToClient({ type: "document_editor_show", conversationId: context.conversationId, surfaceId, title, initialContent, }); context.sendToClient({ type: "ui_surface_show", conversationId: context.conversationId, surfaceId: `preview-${surfaceId}`, surfaceType: "document_preview", display: "inline", title, data: { title, surfaceId, subtitle: "Document", }, }); return { content: JSON.stringify({ surface_id: surfaceId, title, opened: true, message: "Document editor opened in Directory panel", }), isError: false, }; } // Fallback if no client is connected return { content: JSON.stringify({ surface_id: surfaceId, title, opened: false, error: "No client connected to open document editor", }), isError: false, }; } /** * Resolve the target document for an update. An explicit `surface_id` is used * verbatim; when absent, the update targets the conversation's most recently * updated document (`getDocumentsForConversation` orders by `updated_at DESC`), * which is the document being streamed into. This lets a model stream chunks * with only `content` instead of threading the opaque `surface_id` back through * every call — a step weak models routinely drop, leaving the document stuck on * its first chunk. */ function resolveUpdateSurfaceId( input: Record, context: ToolContext, ): ToolExecutionResult | string { if (typeof input.surface_id === "string" && input.surface_id.trim() !== "") { return input.surface_id; } const docs = getDocumentsForConversation(context.conversationId); if (docs.length === 0) { return invalidInput( "surface_id is required: no document is open in this conversation. Call document_create first.", ); } return docs[0].surfaceId; } export function executeDocumentUpdate( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentUpdateInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_update", parsedInput.error); } if (typeof input.content !== "string") { return invalidInput("content is required and must be a string"); } const surfaceIdOrError = resolveUpdateSurfaceId(input, context); if (typeof surfaceIdOrError !== "string") { return surfaceIdOrError; } const surfaceId = surfaceIdOrError; // Loose `!= null` to match validateInputAgainstSchema, which treats null as // "absent" for enum checks — without this, { mode: null } passes the // factory validator but rejects here. The `?? "append"` below handles null. if ( input.mode != null && input.mode !== "replace" && input.mode !== "append" ) { return invalidInput('mode must be "replace" or "append"'); } const content = input.content; const mode = (input.mode as "replace" | "append" | undefined) ?? "append"; if (!canAccessDocument(surfaceId, context)) { return documentNotFound(surfaceId); } const result = updateDocumentContent(surfaceId, content, mode); if (!result.success) { return { content: JSON.stringify({ success: false, surface_id: surfaceId, error: result.error, }), isError: true, }; } // The store drops append content that merely restates the document's tail, // so the client renders what actually landed rather than the submission. const applied = result.appliedMarkdown; const message = result.duplicateLeadingContentSkipped ? applied.length === 0 ? "Nothing appended: the content is already at the end of the document" : "Document content updated (leading content already at the end of the document was skipped)" : "Document content updated"; // Send document_editor_update message to update the built-in RTE if (context.sendToClient) { context.sendToClient({ type: "document_editor_update", conversationId: context.conversationId, surfaceId, markdown: applied, mode, }); return { content: JSON.stringify({ success: true, surface_id: surfaceId, mode, message, }), isError: false, }; } // No client is connected to render the edit, but the write landed, so this is // a success. Reporting it as an error would strand a headless turn (a // schedule, SMS, or Telegram channel): post-execution hooks are skipped for // errored tools, so the documents-changed broadcast that tells clients about // the edit would never fire, and the model would be told to retry a write // that already succeeded. return { content: JSON.stringify({ success: true, surface_id: surfaceId, mode, message: `${message} (no client connected to render it)`, }), isError: false, }; } export function executeDocumentRead( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentReadInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_read", parsedInput.error); } const surfaceIdOrError = validateSurfaceId(input); if (typeof surfaceIdOrError !== "string") { return surfaceIdOrError; } const surfaceId = surfaceIdOrError; if (!canAccessDocument(surfaceId, context)) { return documentNotFound(surfaceId); } const doc = getDocumentById(surfaceId); if (!doc) { return documentNotFound(surfaceId); } return { content: JSON.stringify({ success: true, surface_id: doc.surfaceId, title: doc.title, content: doc.content, word_count: doc.wordCount, updated_at: doc.updatedAt, }), isError: false, }; } export function executeDocumentList( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentListInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_list", parsedInput.error); } const query = parsedInput.data.query?.trim() || undefined; const docs = query ? searchDocumentsByTitle( query, isPrivilegedDocumentActor(context) ? {} : { conversationId: context.conversationId }, ) : getDocumentsForConversation(context.conversationId); return { content: JSON.stringify({ success: true, documents: docs.map((d) => ({ surface_id: d.surfaceId, title: d.title, word_count: d.wordCount, created_at: d.createdAt, updated_at: d.updatedAt, })), }), isError: false, }; } export function executeDocumentDelete( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentDeleteInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_delete", parsedInput.error); } const surfaceIdOrError = validateSurfaceId(input); if (typeof surfaceIdOrError !== "string") { return surfaceIdOrError; } const surfaceId = surfaceIdOrError; if (!canAccessDocument(surfaceId, context)) { return documentNotFound(surfaceId); } const deleted = deleteDocument(surfaceId); if (!deleted) { return documentNotFound(surfaceId); } return { content: JSON.stringify({ success: true, surface_id: surfaceId, message: "Document deleted", }), isError: false, }; } export function executeDocumentFind( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentFindInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_find", parsedInput.error); } const surfaceIdOrError = validateSurfaceId(input); if (typeof surfaceIdOrError !== "string") { return surfaceIdOrError; } const surfaceId = surfaceIdOrError; const query = parsedInput.data.query; const regex = parsedInput.data.regex ?? false; const caseSensitive = parsedInput.data.case_sensitive ?? false; if (!canAccessDocument(surfaceId, context)) { return documentNotFound(surfaceId); } if (regex) { try { new RegExp(query); } catch (e) { return { content: JSON.stringify({ success: false, surface_id: surfaceId, error: `Invalid regex: ${e instanceof Error ? e.message : String(e)}`, }), isError: true, }; } } const result = findInDocument(surfaceId, query, { regex, caseSensitive }); if (!result) { return documentNotFound(surfaceId); } return { content: JSON.stringify({ success: true, surface_id: result.surfaceId, query, total_matches: result.totalMatches, matches: result.matches.map((m) => ({ line_number: m.lineNumber, line_content: m.lineContent, match_start: m.matchStart, match_end: m.matchEnd, match_text: m.matchText, })), }), isError: false, }; } export function executeDocumentReplaceText( input: Record, context: ToolContext, ): ToolExecutionResult { const parsedInput = documentReplaceTextInputSchema.safeParse(input); if (!parsedInput.success) { return invalidToolInputResult("document_replace_text", parsedInput.error); } const surfaceIdOrError = validateSurfaceId(input); if (typeof surfaceIdOrError !== "string") { return surfaceIdOrError; } const surfaceId = surfaceIdOrError; const find = parsedInput.data.find; const replace = parsedInput.data.replace ?? ""; const regex = parsedInput.data.regex ?? false; const caseSensitive = parsedInput.data.case_sensitive ?? false; const maxReplacements = parsedInput.data.max_replacements; if (!canAccessDocument(surfaceId, context)) { return documentNotFound(surfaceId); } if (regex) { try { new RegExp(find); } catch (err) { return { content: JSON.stringify({ success: false, error: `Invalid regex: ${err instanceof Error ? err.message : String(err)}`, }), isError: true, }; } } const result = replaceInDocument(surfaceId, find, replace, { regex, caseSensitive, maxReplacements, }); if (!result.success) { return { content: JSON.stringify({ success: false, surface_id: surfaceId, error: result.error, }), isError: true, }; } if (context.sendToClient && result.content_changed) { const doc = getDocumentById(surfaceId); if (doc) { context.sendToClient({ type: "document_editor_update", conversationId: context.conversationId, surfaceId, markdown: doc.content, mode: "replace", }); } } return { content: JSON.stringify({ success: true, surface_id: surfaceId, replacements_made: result.replacements_made, content_changed: result.content_changed, }), isError: false, }; }