UNPKG

@stll/folio-agents

Version:

Framework-neutral LLM tool layer over folio's ai-edits engine: function-calling tools so an agent can read and mutate .docx documents through @stll/folio-core.

704 lines (703 loc) 21.1 kB
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_MODES, InvalidFolioDocumentOperationBatchError, UnsupportedFolioDocumentOperationVersionError, parseFolioDocumentOperationBatch } from "@stll/folio-core/server"; //#region src/operation-schema.ts /** * Reusable projections of folio's versioned document-operation contract for * tool builders: a JSON Schema for one operation, a JSON Schema for the * versioned batch envelope, and a Standard Schema V1 wrapper around * `parseFolioDocumentOperationBatch` so downstream tool definitions (e.g. * TanStack AI tools) can consume the contract directly instead of * hand-mirroring it in valibot/zod. * * The strict throwing parser in `@stll/folio-core` stays the single source of * truth for semantics; everything here is a projection of it. Constraints * JSON Schema cannot express (`endOffset > startOffset`, unique operation * ids within a batch, per-type mode support) are noted in `description`s and * enforced by the parser. */ /** * Pattern for the normalized text hashes the contract uses to detect stale * targets (`selectedTextHash`, `precondition.blockTextHash`). Mirrors the * parser's check in `@stll/folio-core`'s `document-operations.ts`. */ const NORMALIZED_TEXT_HASH_PATTERN = "^h[0-9a-z]+$"; /** * JSON Schema for the contract's `textRange` handle (a serializable range * over the visible text of one main-story block, as returned by `find_text` * style tools). Shared between {@link FOLIO_DOCUMENT_OPERATION_JSON_SCHEMA} * and the `suggest_changes` tool schema in `tools.ts` so the two cannot * drift. `endOffset` must be greater than `startOffset`; JSON Schema cannot * express that relation, the parser enforces it. */ const FOLIO_TEXT_RANGE_JSON_SCHEMA = { type: "object", description: "A range over the visible text of one main-story block. Offsets are zero-based UTF-16 boundaries; `endOffset` must be greater than `startOffset`. Copy range objects verbatim from the tool that produced them — `selectedTextHash` makes a shifted or edited selection fail as stale instead of hitting the wrong text.", properties: { type: { type: "string", enum: ["textRange"] }, story: { type: "string", enum: ["main"] }, blockId: { type: "string", minLength: 1 }, startOffset: { type: "integer", minimum: 0 }, endOffset: { type: "integer", minimum: 1, description: "Exclusive end offset; must be greater than `startOffset`." }, selectedTextHash: { type: "string", pattern: NORMALIZED_TEXT_HASH_PATTERN, description: "Normalized hash of the selected text, used to detect stale ranges." } }, required: [ "type", "story", "blockId", "startOffset", "endOffset", "selectedTextHash" ], additionalProperties: false }; /** * JSON Schema for the contract's optional `precondition` guard: `{ * blockTextHash }`, echoed from a `blockTextHash` returned by a document * read (`read_document`, `read_section`, `find_text`). Exported so * `tools.ts` can attach the same shape to `suggest_changes` and * `add_comment` without redeclaring it. */ const FOLIO_PRECONDITION_JSON_SCHEMA = { type: "object", description: "Optional guard against editing a block that changed since you read it: echo the `blockTextHash` returned by read_document / read_section / find_text for this block. The operation is skipped (reason `preconditionFailed`) unless the target block's current normalized text hash still matches.", properties: { blockTextHash: { type: "string", pattern: NORMALIZED_TEXT_HASH_PATTERN, description: "Normalized hash of the target block's text, from a prior document read." } }, required: ["blockTextHash"], additionalProperties: false }; const commentJsonSchema = { type: "object", description: "A comment attached to the text affected by this operation.", properties: { text: { type: "string", description: "The comment body." } }, required: ["text"], additionalProperties: false }; /** * Properties every operation variant accepts: the required `id` plus the * optional review metadata (`severity`, `area`) and `precondition` guard. * `suggestionId` joins them on every variant except `commentOnRange` * (comments are not tracked changes, so there is nothing to group). */ const operationMetaProperties = { id: { type: "string", description: "Caller-supplied operation id, echoed back in `applied` / `skipped` results. Must be unique within a batch (enforced by the parser)." }, severity: { type: "string", enum: [ "low", "medium", "high" ], description: "Optional review severity for structured-review workflows." }, area: { type: "string", description: "Optional review area label (e.g. \"Penalty\") for structured-review workflows." }, precondition: FOLIO_PRECONDITION_JSON_SCHEMA }; const suggestionIdProperty = { suggestionId: { type: "string", description: "Optional host-side grouping key: in \"suggested\" mode every mark this operation produces carries it so the whole suggestion is accepted or rejected as one unit." } }; const blockIdProperty = { type: "string", description: "Id of the target block, from a prior document read." }; /** * JSON Schema (draft-07 compatible) for ONE document operation: the full * union accepted by `parseFolioDocumentOperationBatch` in * `@stll/folio-core`, one `oneOf` variant per entry in * `FOLIO_DOCUMENT_OPERATION_TYPES`. Intended for LLM tool definitions and * other consumers that need the contract's wire shape without re-declaring * it; note that the `suggest_changes` tool in `tools.ts` deliberately * narrows this union (see the comment there). */ const FOLIO_DOCUMENT_OPERATION_JSON_SCHEMA = { description: "One document operation, discriminated by `type`. Mirrors the operation union accepted by `parseFolioDocumentOperationBatch` in @stll/folio-core.", oneOf: [ { type: "object", description: "Replace an exact text match inside one block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["replaceInBlock"] }, blockId: blockIdProperty, find: { type: "string", description: "The exact text to find within the block." }, replace: { type: "string", description: "The replacement text." }, comment: commentJsonSchema }, required: [ "id", "type", "blockId", "find", "replace" ], additionalProperties: false }, { type: "object", description: "Replace the text covered by a range handle.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["replaceRange"] }, range: FOLIO_TEXT_RANGE_JSON_SCHEMA, replace: { type: "string", description: "The replacement text." }, comment: commentJsonSchema }, required: [ "id", "type", "range", "replace" ], additionalProperties: false }, { type: "object", description: "Attach a comment to the text covered by a range handle.", properties: { ...operationMetaProperties, type: { type: "string", enum: ["commentOnRange"] }, range: FOLIO_TEXT_RANGE_JSON_SCHEMA, comment: commentJsonSchema }, required: [ "id", "type", "range", "comment" ], additionalProperties: false }, { type: "object", description: "Apply inline formatting to the text covered by a range handle in direct or tracked mode.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["formatRange"] }, range: FOLIO_TEXT_RANGE_JSON_SCHEMA, formatting: { type: "object", description: "Inline formatting to apply; at least one property is required.", properties: { bold: { type: "boolean" }, italic: { type: "boolean" }, underline: { type: "boolean" } }, minProperties: 1, additionalProperties: false } }, required: [ "id", "type", "range", "formatting" ], additionalProperties: false }, { type: "object", description: "Insert a new paragraph after the anchor block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["insertAfterBlock"] }, blockId: blockIdProperty, text: { type: "string", description: "The paragraph text to insert. A line break splits this into consecutive paragraphs at the same anchor instead of one paragraph with embedded newlines — only the first paragraph gets `styleId` / `inheritFormatting`, later ones use body formatting. Prefer one paragraph per operation; only rely on the split for a heading immediately followed by its body text." }, inheritFormatting: { type: "boolean", description: "Inherit the anchor block's formatting for the inserted paragraph." }, pageBreakBefore: { type: "boolean", description: "Start the inserted paragraph on a new page (`pageBreakBefore`)." }, styleId: { type: "string", description: "Paragraph style id for the inserted block (e.g. \"ClauseHeading1\")." }, comment: commentJsonSchema }, required: [ "id", "type", "blockId", "text" ], additionalProperties: false }, { type: "object", description: "Insert a new paragraph before the anchor block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["insertBeforeBlock"] }, blockId: blockIdProperty, text: { type: "string", description: "The paragraph text to insert. A line break splits this into consecutive paragraphs at the same anchor instead of one paragraph with embedded newlines — only the first paragraph gets `styleId` / `inheritFormatting`, later ones use body formatting. Prefer one paragraph per operation; only rely on the split for a heading immediately followed by its body text." }, inheritFormatting: { type: "boolean", description: "Inherit the anchor block's formatting for the inserted paragraph." }, pageBreakBefore: { type: "boolean", description: "Start the inserted paragraph on a new page (`pageBreakBefore`)." }, styleId: { type: "string", description: "Paragraph style id for the inserted block (e.g. \"ClauseHeading1\")." }, comment: commentJsonSchema }, required: [ "id", "type", "blockId", "text" ], additionalProperties: false }, { type: "object", description: "Replace one block's entire text.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["replaceBlock"] }, blockId: blockIdProperty, text: { type: "string", description: "The new block text." }, preserveFormatting: { type: "boolean", description: "Keep the block's existing formatting for the replacement text." }, styleId: { type: "string", description: "Paragraph style id to set on the replaced block." }, comment: commentJsonSchema }, required: [ "id", "type", "blockId", "text" ], additionalProperties: false }, { type: "object", description: "Delete one block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["deleteBlock"] }, blockId: blockIdProperty, comment: commentJsonSchema }, required: [ "id", "type", "blockId" ], additionalProperties: false }, { type: "object", description: "Attach a comment to one block, optionally quoting text within it.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["commentOnBlock"] }, blockId: blockIdProperty, quote: { type: "string", description: "Exact text within the block the comment is about." }, comment: commentJsonSchema }, required: [ "id", "type", "blockId", "comment" ], additionalProperties: false }, { type: "object", description: "Insert a signature table next to the anchor block. Direct mode only (table insertion is not representable as a tracked change).", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["insertSignatureTable"] }, blockId: blockIdProperty, position: { type: "string", enum: ["after", "before"], description: "Insert after the anchor block (default) or before it. Defaults to \"after\"." }, parties: { type: "array", description: "The signing parties, one table cell per party.", items: { type: "object", properties: { name: { type: "string", description: "Party name (rendered bold)." }, signatory: { type: "string", description: "Name of the person signing." }, title: { type: "string", description: "Signatory title (rendered in italics)." } }, required: ["name"], additionalProperties: false } }, comment: commentJsonSchema }, required: [ "id", "type", "blockId", "parties" ], additionalProperties: false }, { type: "object", description: "Insert a row next to the row containing the anchor block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["insertTableRow"] }, blockId: blockIdProperty, position: { type: "string", enum: ["after", "before"], description: "Insert after the anchor row (default) or before it. Defaults to \"after\"." }, cellTexts: { type: "array", description: "Initial text for physical cells in source order; omitted cells stay empty.", items: { type: "string" } } }, required: [ "id", "type", "blockId" ], additionalProperties: false }, { type: "object", description: "Delete the row containing the anchor block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["deleteTableRow"] }, blockId: blockIdProperty }, required: [ "id", "type", "blockId" ], additionalProperties: false }, { type: "object", description: "Insert a column next to the column containing the anchor block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["insertTableColumn"] }, blockId: blockIdProperty, position: { type: "string", enum: ["after", "before"], description: "Insert after the anchor column (default) or before it. Defaults to \"after\"." }, cellTexts: { type: "array", description: "Initial text for newly created physical cells in row order.", items: { type: "string" } } }, required: [ "id", "type", "blockId" ], additionalProperties: false }, { type: "object", description: "Delete the column containing the anchor block.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["deleteTableColumn"] }, blockId: blockIdProperty }, required: [ "id", "type", "blockId" ], additionalProperties: false }, { type: "object", description: "Merge a region targeted by an opposite-cell anchor or a downward row count. Tracked mode supports vertical-only regions with empty continuation cells.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["mergeTableCells"] }, blockId: blockIdProperty, endBlockId: { type: "string", minLength: 1, description: "Stable paragraph anchor inside the opposite corner cell." }, rowCount: { type: "integer", minimum: 2, description: "Number of grid rows to merge downward from the anchored cell." } }, required: [ "id", "type", "blockId" ], oneOf: [{ required: ["endBlockId"] }, { required: ["rowCount"] }], additionalProperties: false }, { type: "object", description: "Split one spanned table cell into individual cells. Tracked mode supports vertical-only spans.", properties: { ...operationMetaProperties, ...suggestionIdProperty, type: { type: "string", enum: ["splitTableCell"] }, blockId: blockIdProperty }, required: [ "id", "type", "blockId" ], additionalProperties: false } ] }; /** * JSON Schema (draft-07 compatible) for the versioned batch envelope accepted * by `parseFolioDocumentOperationBatch`: `version` (always * `FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION`), `operations`, and the * optional `mode` / `atomic` / `dryRun` flags the exact wire shape of * `FolioDocumentOperationBatch`. Hand this to an LLM tool definition (or any * JSON Schema consumer) instead of re-declaring the contract. */ const FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA = { type: "object", description: "A versioned batch of document operations. Operation ids must be unique within the batch (enforced by the parser, not expressible in JSON Schema).", properties: { version: { type: "integer", enum: [FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION], description: "Document-operation contract version." }, operations: { type: "array", description: "The operations to apply, in order.", items: FOLIO_DOCUMENT_OPERATION_JSON_SCHEMA }, mode: { type: "string", enum: FOLIO_DOCUMENT_OPERATION_MODES, description: "How edits land: \"tracked-changes\" (default) proposes revisions for human review, \"direct\" applies immediately. `insertSignatureTable` supports \"direct\" only. Tracked cell merge and split operations support unambiguous vertical-only topology." }, atomic: { type: "boolean", description: "Reject the whole batch when any operation would be skipped." }, dryRun: { type: "boolean", description: "Preview the batch without mutating the document." }, precondition: { type: "object", description: "Batch-level guard: the host document version this batch was authored against. A surface that knows its document version skips the whole batch (reason `documentVersionMismatch`) when the token differs.", properties: { documentVersion: { type: "string", minLength: 1, description: "Opaque host version token (an entity version id, a checkpoint)." } }, required: ["documentVersion"], additionalProperties: false } }, required: ["version", "operations"], additionalProperties: false }; /** * Convert the parser's JSONPath-style location (`$.operations[0].blockId`) * into a Standard Schema issue path (`["operations", 0, "blockId"]`). * Returns `undefined` for the root path so root-level issues carry no path. */ const toStandardSchemaPath = (path) => { const segments = []; for (const match of path.matchAll(/\.(?<key>[^.[\]]+)|\[(?<index>\d+)\]/gu)) { const { key, index } = match.groups ?? {}; if (key !== void 0) segments.push(key); else if (index !== void 0) segments.push(Number(index)); } return segments.length > 0 ? segments : void 0; }; const toStandardSchemaIssue = (error) => { if (error instanceof InvalidFolioDocumentOperationBatchError) { const path = toStandardSchemaPath(error.path); return { message: error.message, ...path !== void 0 && { path } }; } if (error instanceof UnsupportedFolioDocumentOperationVersionError) return { message: error.message, path: ["version"] }; return { message: error instanceof Error ? error.message : String(error) }; }; /** * Standard Schema V1 (https://standardschema.dev) validator for a document * operation batch, for spec-aware consumers (TanStack AI tool `inputSchema`, * tRPC input, etc.). Delegates to `parseFolioDocumentOperationBatch` from * `@stll/folio-core`, so the strict parser stays the single source of truth: * `validate` never throws, returning `{ value }` with the parser's exact * output on success and `{ issues }` (message plus a key path when the parser * reported one) on failure. The matching LLM-facing JSON schema is attached * as {@link FolioDocumentOperationBatchSchema.jsonSchema}. */ const folioDocumentOperationBatchSchema = { "~standard": { version: 1, vendor: "folio", validate: (value) => { try { return { value: parseFolioDocumentOperationBatch(value) }; } catch (error) { return { issues: [toStandardSchemaIssue(error)] }; } } }, jsonSchema: FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA }; //#endregion export { FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA, FOLIO_DOCUMENT_OPERATION_JSON_SCHEMA, FOLIO_PRECONDITION_JSON_SCHEMA, FOLIO_TEXT_RANGE_JSON_SCHEMA, folioDocumentOperationBatchSchema };