@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
JavaScript
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 };