@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.
1,113 lines (1,112 loc) • 34 kB
JavaScript
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_MODES, FOLIO_LINE_SPACING_RULE_VALUES, FOLIO_PAGE_BREAK_CLEAR_VALUES, FOLIO_PARAGRAPH_ALIGNMENT_VALUES, 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,
maximum: Number.MAX_SAFE_INTEGER
},
endOffset: {
type: "integer",
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER,
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."
};
const FOLIO_CLEARABLE_PARAGRAPH_STYLE_ID_JSON_SCHEMA = {
oneOf: [{ type: "string" }, { type: "null" }],
description: "Paragraph style id; it must name a paragraph style the document defines, or the operation skips with `missingStyle`. null clears the direct style."
};
/** Direct `w:outlineLvl`, or null to restore style inheritance. */
const FOLIO_CLEARABLE_OUTLINE_LEVEL_JSON_SCHEMA = {
oneOf: [
{
type: "object",
properties: {
kind: {
type: "string",
enum: ["heading"]
},
level: {
type: "integer",
minimum: 0,
maximum: 8
}
},
required: ["kind", "level"],
additionalProperties: false
},
{
type: "object",
properties: { kind: {
type: "string",
enum: ["bodyText"]
} },
required: ["kind"],
additionalProperties: false
},
{ type: "null" }
],
description: "Direct outline level (`w:outlineLvl`): a heading level from 0 (top) to 8, or body text. null removes it and lets the paragraph style decide."
};
/** Complete direct `w:ind` attributes, or null to restore style inheritance. */
const FOLIO_CLEARABLE_PARAGRAPH_INDENTATION_JSON_SCHEMA = {
oneOf: [{
type: "object",
properties: {
indentLeft: {
type: "integer",
minimum: Number.MIN_SAFE_INTEGER,
maximum: Number.MAX_SAFE_INTEGER
},
indentRight: {
type: "integer",
minimum: Number.MIN_SAFE_INTEGER,
maximum: Number.MAX_SAFE_INTEGER
},
indentFirstLine: {
type: "integer",
minimum: Number.MIN_SAFE_INTEGER,
maximum: Number.MAX_SAFE_INTEGER
},
hangingIndent: { type: "boolean" }
},
minProperties: 1,
additionalProperties: false
}, { type: "null" }],
description: "Complete direct paragraph indentation (`w:ind`); null removes the direct child and restores style inheritance."
};
const FOLIO_INSERT_FORMATTING_SCOPE_JSON_SCHEMA = {
type: "string",
enum: ["firstParagraph", "allParagraphs"],
description: "Which paragraphs split from `text` receive the operation's paragraph formatting: `firstParagraph` (default) formats only the first, for a heading followed by its body; `allParagraphs` formats every paragraph alike, for several list items in one operation."
};
const FOLIO_PARAGRAPH_NUMBERING_JSON_SCHEMA = {
oneOf: [
{
type: "object",
properties: { kind: {
type: "string",
enum: ["none"]
} },
required: ["kind"],
additionalProperties: false
},
{
type: "object",
properties: { kind: {
type: "string",
enum: ["inherit"]
} },
required: ["kind"],
additionalProperties: false
},
{
type: "object",
properties: {
kind: {
type: "string",
enum: ["levelOnly"]
},
ilvl: {
type: "integer",
minimum: 0,
maximum: 8
}
},
required: ["kind", "ilvl"],
additionalProperties: false
},
{
type: "object",
properties: {
kind: {
type: "string",
enum: ["reference"]
},
numId: {
type: "integer",
minimum: 1,
maximum: Number.MAX_SAFE_INTEGER
},
ilvl: {
type: "integer",
minimum: 0,
maximum: 8
}
},
required: ["kind", "numId"],
additionalProperties: false
},
{
type: "object",
properties: {
kind: {
type: "string",
enum: ["newList"]
},
format: {
type: "string",
enum: ["bullet", "numbered"]
},
level: {
type: "integer",
minimum: 0,
maximum: 8
}
},
required: ["kind", "format"],
additionalProperties: false
}
],
description: "A paragraph numbering request: `none` cancels numbering, `inherit` removes the direct numbering reference, `levelOnly` sets only `w:ilvl`, `reference` selects a defined numbering instance with an optional level override, and `newList` starts a list with the requested format."
};
const FOLIO_HARD_PAGE_BREAK_JSON_SCHEMA = {
type: "object",
properties: { clear: {
type: "string",
enum: FOLIO_PAGE_BREAK_CLEAR_VALUES
} },
additionalProperties: false,
description: "Insert an empty hard page-break run; it cannot be combined with text, pageBreakBefore, or lineBreakMode."
};
/** Shared cross-field constraint for insertion and suggest-changes projections. */
const FOLIO_HARD_PAGE_BREAK_OPERATION_CONSTRAINT = { oneOf: [{ not: { required: ["hardPageBreak"] } }, {
required: ["hardPageBreak", "text"],
properties: {
type: { enum: ["insertAfterBlock", "insertBeforeBlock"] },
text: { enum: [""] },
pageBreakBefore: { not: {} },
lineBreakMode: { not: {} }
}
}] };
/** Complete direct `w:spacing` attributes, or null to restore style inheritance. */
const FOLIO_CLEARABLE_PARAGRAPH_SPACING_JSON_SCHEMA = {
oneOf: [{
type: "object",
properties: {
spaceBefore: {
type: "integer",
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
description: "Non-negative space before, in twips."
},
spaceAfter: {
type: "integer",
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
description: "Non-negative space after, in twips."
},
lineSpacing: {
type: "integer",
minimum: Number.MIN_SAFE_INTEGER,
maximum: Number.MAX_SAFE_INTEGER,
description: "Signed line spacing: 240ths of a line with the auto rule, otherwise twips."
},
lineSpacingRule: {
type: "string",
enum: FOLIO_LINE_SPACING_RULE_VALUES,
description: "How `lineSpacing` is interpreted."
},
beforeAutospacing: {
type: "boolean",
description: "Whether the application determines spacing before automatically."
},
afterAutospacing: {
type: "boolean",
description: "Whether the application determines spacing after automatically."
}
},
minProperties: 1,
additionalProperties: false
}, { type: "null" }],
description: "Complete direct paragraph spacing (`w:spacing`); omitted attributes stay absent, while zero and false remain explicit. Null removes the direct spacing child and restores style inheritance."
};
const FOLIO_BLOCK_PARAGRAPH_PROPERTIES_JSON_SCHEMA = {
type: "object",
description: "The paragraph properties to set; at least one.",
properties: {
styleId: FOLIO_CLEARABLE_PARAGRAPH_STYLE_ID_JSON_SCHEMA,
outlineLevel: FOLIO_CLEARABLE_OUTLINE_LEVEL_JSON_SCHEMA,
numbering: FOLIO_PARAGRAPH_NUMBERING_JSON_SCHEMA,
indentation: FOLIO_CLEARABLE_PARAGRAPH_INDENTATION_JSON_SCHEMA,
alignment: {
oneOf: [{
type: "string",
enum: FOLIO_PARAGRAPH_ALIGNMENT_VALUES
}, { type: "null" }],
description: "Direct paragraph alignment; null restores style inheritance."
},
spacing: FOLIO_CLEARABLE_PARAGRAPH_SPACING_JSON_SCHEMA
},
minProperties: 1,
additionalProperties: false
};
/**
* 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.",
...FOLIO_HARD_PAGE_BREAK_OPERATION_CONSTRAINT,
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. By default only the first paragraph gets `styleId` / `numbering` / `alignment` / `spacing` / `inheritFormatting` and later ones use body formatting; set `formattingScope: \"allParagraphs\"` to format every paragraph alike, for example several list items. Prefer one paragraph per operation otherwise."
},
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: FOLIO_CLEARABLE_PARAGRAPH_STYLE_ID_JSON_SCHEMA,
outlineLevel: FOLIO_CLEARABLE_OUTLINE_LEVEL_JSON_SCHEMA,
alignment: {
oneOf: [{
type: "string",
enum: FOLIO_PARAGRAPH_ALIGNMENT_VALUES
}, { type: "null" }],
description: "Direct paragraph alignment; null restores style inheritance."
},
spacing: FOLIO_CLEARABLE_PARAGRAPH_SPACING_JSON_SCHEMA,
indentation: FOLIO_CLEARABLE_PARAGRAPH_INDENTATION_JSON_SCHEMA,
lineBreakMode: {
type: "string",
enum: ["paragraph", "inline"]
},
formattingScope: FOLIO_INSERT_FORMATTING_SCOPE_JSON_SCHEMA,
numbering: FOLIO_PARAGRAPH_NUMBERING_JSON_SCHEMA,
hardPageBreak: FOLIO_HARD_PAGE_BREAK_JSON_SCHEMA,
moveId: {
type: "string",
description: "Links this operation to the other half of one relocation: a `deleteBlock` and an insertion sharing a `moveId` are written as `w:moveFrom` / `w:moveTo`. An id that does not name exactly one of each applies as an ordinary edit."
},
comment: commentJsonSchema
},
required: [
"id",
"type",
"blockId",
"text"
],
additionalProperties: false
},
{
type: "object",
description: "Insert a new paragraph before the anchor block.",
...FOLIO_HARD_PAGE_BREAK_OPERATION_CONSTRAINT,
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. By default only the first paragraph gets `styleId` / `numbering` / `alignment` / `spacing` / `inheritFormatting` and later ones use body formatting; set `formattingScope: \"allParagraphs\"` to format every paragraph alike, for example several list items. Prefer one paragraph per operation otherwise."
},
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: FOLIO_CLEARABLE_PARAGRAPH_STYLE_ID_JSON_SCHEMA,
outlineLevel: FOLIO_CLEARABLE_OUTLINE_LEVEL_JSON_SCHEMA,
alignment: {
oneOf: [{
type: "string",
enum: FOLIO_PARAGRAPH_ALIGNMENT_VALUES
}, { type: "null" }],
description: "Direct paragraph alignment; null restores style inheritance."
},
spacing: FOLIO_CLEARABLE_PARAGRAPH_SPACING_JSON_SCHEMA,
indentation: FOLIO_CLEARABLE_PARAGRAPH_INDENTATION_JSON_SCHEMA,
lineBreakMode: {
type: "string",
enum: ["paragraph", "inline"]
},
formattingScope: FOLIO_INSERT_FORMATTING_SCOPE_JSON_SCHEMA,
numbering: FOLIO_PARAGRAPH_NUMBERING_JSON_SCHEMA,
hardPageBreak: FOLIO_HARD_PAGE_BREAK_JSON_SCHEMA,
moveId: {
type: "string",
description: "Links this operation to the other half of one relocation: a `deleteBlock` and an insertion sharing a `moveId` are written as `w:moveFrom` / `w:moveTo`. An id that does not name exactly one of each applies as an ordinary edit."
},
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: FOLIO_CLEARABLE_PARAGRAPH_STYLE_ID_JSON_SCHEMA,
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,
moveId: {
type: "string",
description: "Links this operation to the other half of one relocation: a `deleteBlock` and an insertion sharing a `moveId` are written as `w:moveFrom` / `w:moveTo`. An id that does not name exactly one of each applies as an ordinary edit."
},
comment: commentJsonSchema
},
required: [
"id",
"type",
"blockId"
],
additionalProperties: false
},
{
type: "object",
description: "Break one block in two at `offset`: a paragraph mark is inserted and no words change.",
properties: {
...operationMetaProperties,
...suggestionIdProperty,
type: {
type: "string",
enum: ["splitBlock"]
},
blockId: blockIdProperty,
offset: {
type: "integer",
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
description: "Character offset in the block's text where the break goes. Must fall strictly inside it."
},
separator: {
type: "string",
description: "Text at `offset` the break replaces, usually the space between the halves."
},
firstParagraphProperties: {
...FOLIO_BLOCK_PARAGRAPH_PROPERTIES_JSON_SCHEMA,
description: "Properties for the first result; omitted properties keep the source paragraph's value."
},
secondParagraphProperties: {
...FOLIO_BLOCK_PARAGRAPH_PROPERTIES_JSON_SCHEMA,
description: "Properties for the second result; omitted properties keep the source paragraph's value."
}
},
required: [
"id",
"type",
"blockId",
"offset"
],
additionalProperties: false
},
{
type: "object",
description: "Join one block with the block after it in the same container: a paragraph mark is deleted.",
properties: {
...operationMetaProperties,
...suggestionIdProperty,
type: {
type: "string",
enum: ["mergeBlockWithNext"]
},
blockId: blockIdProperty,
separator: {
type: "string",
description: "Text the join inserts between the two halves, usually a space."
},
mergedParagraphProperties: {
...FOLIO_BLOCK_PARAGRAPH_PROPERTIES_JSON_SCHEMA,
description: "Properties for the joined result; omitted properties keep the first paragraph's value."
}
},
required: [
"id",
"type",
"blockId"
],
additionalProperties: false
},
{
type: "object",
description: "Change one block's paragraph properties without touching its words, recorded as a `w:pPrChange`.",
properties: {
...operationMetaProperties,
...suggestionIdProperty,
type: {
type: "string",
enum: ["setBlockParagraphProperties"]
},
blockId: blockIdProperty,
properties: FOLIO_BLOCK_PARAGRAPH_PROPERTIES_JSON_SCHEMA
},
required: [
"id",
"type",
"blockId",
"properties"
],
additionalProperties: false
},
{
type: "object",
description: "Insert a whole table next to the anchor block.",
properties: {
...operationMetaProperties,
...suggestionIdProperty,
type: {
type: "string",
enum: ["insertTable"]
},
blockId: blockIdProperty,
position: {
type: "string",
enum: ["after", "before"],
description: "Place the table after the anchor (default) or before it."
},
rows: {
type: "array",
description: "Cell texts row by row; every row must hold the same number of cells.",
items: {
type: "array",
items: { type: "string" }
}
}
},
required: [
"id",
"type",
"blockId",
"rows"
],
additionalProperties: false
},
{
type: "object",
description: "Delete the whole table the anchor block sits in.",
properties: {
...operationMetaProperties,
...suggestionIdProperty,
type: {
type: "string",
enum: ["deleteTable"]
},
blockId: blockIdProperty
},
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,
maximum: Number.MAX_SAFE_INTEGER,
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_BLOCK_PARAGRAPH_PROPERTIES_JSON_SCHEMA, FOLIO_CLEARABLE_OUTLINE_LEVEL_JSON_SCHEMA, FOLIO_CLEARABLE_PARAGRAPH_INDENTATION_JSON_SCHEMA, FOLIO_CLEARABLE_PARAGRAPH_SPACING_JSON_SCHEMA, FOLIO_CLEARABLE_PARAGRAPH_STYLE_ID_JSON_SCHEMA, FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA, FOLIO_DOCUMENT_OPERATION_JSON_SCHEMA, FOLIO_HARD_PAGE_BREAK_JSON_SCHEMA, FOLIO_HARD_PAGE_BREAK_OPERATION_CONSTRAINT, FOLIO_INSERT_FORMATTING_SCOPE_JSON_SCHEMA, FOLIO_PARAGRAPH_NUMBERING_JSON_SCHEMA, FOLIO_PRECONDITION_JSON_SCHEMA, FOLIO_TEXT_RANGE_JSON_SCHEMA, folioDocumentOperationBatchSchema };