@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.
326 lines (265 loc) • 14.2 kB
Markdown
# /folio-agents
A framework-neutral (React-free, DOM-free) LLM tool layer over
[`/folio-core`](https://www.npmjs.com/package/@stll/folio-core)'s AI-edits
engine: function-calling tool definitions and an executor so a model can read
and mutate a `.docx` document, with every mutation landing as a tracked change
or comment pending human review.
Part of [stella](https://github.com/stella/stella), an open-source legal workspace.
## Install
```sh
bun add /folio-agents
```
`/folio-core` is installed automatically as a dependency.
## Tools
| Tool | What it does |
| ---------------------- | ---------------------------------------------------------------------- |
| `read_document` | Read the document body as `{ blockId, kind, text }` blocks |
| `get_document_outline` | Read heading hierarchy and stable section handles |
| `read_section` | Read a bounded logical section, with block cursor pagination |
| `list_stories` | List main, header, footer, footnote, and endnote story handles |
| `read_story` | Read one story by its typed handle |
| `find_text` | Search by document, section, real page, or story scope |
| `read_comments` | Read comment threads (author, text, resolved, anchored block, replies) |
| `read_changes` | Read pending tracked changes (insertions/deletions) awaiting review |
| `add_comment` | Attach a comment to a block, optionally quoting specific text |
| `suggest_changes` | Propose block or stable-range edits as tracked changes |
| `reply_comment` | Reply to a comment thread |
| `resolve_comment` | Resolve or reopen a comment thread |
| `read_page` | Read a page's plain text (live editor only) |
| `read_selection` | Read the current text selection (live editor only) |
| `scroll_to_block` | Scroll the live editor to a block (live editor only) |
| `show_in_document` | Reveal a stable block or exact text range (live editor only) |
Block ids and comment ids always come from a prior tool call
(`read_document`, `find_text`, `read_comments`) within the same conversation —
never guess them. `suggest_changes` reports a plain-language reason when an
operation is skipped (e.g. the block changed since it was last read), so the
model can re-read and retry. Successful mutation results include `receipts`
that identify affected blocks, ranges, insertions, and created comments.
For document questions, start with `get_document_outline`, then call
`read_section` or scoped `find_text`. This keeps unrelated contract text out
of model context. Section handles use Folio block identities, heading depth,
and a text hash, so structurally changed, renamed, or deleted headings fail
stale instead of resolving to the wrong content. Page scopes and page numbers
use Folio's live layout; they are never approximated from character counts and
therefore require a live, paginated editor.
### Untrusted documents
`read_document`, `read_section`, `read_story`, `read_page`, `read_comments`,
`read_changes`, and `find_text` return document content verbatim. If a `.docx`
comes from an untrusted party, its text can carry prompt-injection payloads
straight into the model's context —
treat any document-derived tool result as untrusted model input, the same way
you would treat a fetched web page. Mutations stay safe by design regardless:
`suggest_changes` and `add_comment` land as tracked changes or comments
pending human review, so an injected instruction can propose an edit but
cannot silently apply one.
## Headless quickstart
```ts
import { FolioDocxReviewer } from "@stll/folio-core/server";
import {
createReviewerBridge,
executeFolioToolCallUntyped,
getFolioToolDefinitions,
toAnthropicTools,
} from "@stll/folio-agents";
const reviewer = await FolioDocxReviewer.fromBuffer(docxBuffer, { author: "AI" });
const bridge = createReviewerBridge(reviewer);
const tools = toAnthropicTools(getFolioToolDefinitions());
// Inside your tool-use loop, for each tool_use block the model emits:
const result = executeFolioToolCallUntyped(toolName, toolInput, bridge);
// result: { ok: true, result } | { ok: false, error } — feed either back to the model.
const reviewedBuffer = await reviewer.toBuffer();
```
## Live-editor quickstart
```ts
import { createEditorRefBridge, executeFolioToolCall } from "@stll/folio-agents";
// `docxEditorRef` is a DocxEditorRef from @stll/folio-react (or any object
// structurally matching FolioAgentEditorRefLike).
const bridge = createEditorRefBridge({
ref: docxEditorRef.current,
author: "AI",
getComments: () => comments,
setComments: (next) => setComments(next),
});
const result = executeFolioToolCall("suggest_changes", { operations: [...] }, bridge);
```
On a `DocxEditorRef` that implements the read surface (`getTrackedChanges`,
`getCommentAnchors`, `getSelectionText`, `getPageText`, `getTargetPage`, and
`showInDocument`), the editor-ref bridge has full parity with the headless one:
`read_changes` returns real tracked changes, comment entries carry a resolved
`blockId` / `quote`, and `read_page` / `read_selection` work against the live
view. Against an older ref that predates those methods, the bridge degrades
per-member: `read_changes`
returns `[]`, comment entries fall back to `blockId: null` / `quote: ""`, and
`read_page` / `read_selection` report an unsupported-capability error — see
`src/bridges/editor-ref.ts` for the exact fallback per method.
### Configuring `suggest_changes`
`suggest_changes` is a projection of the document-operation contract that a
host configures per surface instead of re-deriving. Pass the same options to
`getFolioToolDefinitions` (they shape the JSON Schema the model sees) and to
`executeFolioToolCall` (they drive the parser), so the two cannot drift:
```ts
import {
describeSuggestChangesCapabilities,
executeFolioToolCall,
getFolioToolDefinitions,
type FolioSuggestChangesOptions,
} from "@stll/folio-agents";
const suggestChanges: FolioSuggestChangesOptions = {
// Any subset of the contract types, e.g. a text-only surface:
operationTypes: ["replaceInBlock", "replaceBlock", "deleteBlock"],
// Force `severity` and `area` on every operation for a review queue:
reviewMeta: "required",
// Per-call cap, 1 to 200 (default 50):
maxOperations: 200,
// Pin the batch to a host document version the model must echo back:
documentVersion: { current: entityVersionId },
};
const tools = getFolioToolDefinitions({ suggestChanges });
const result = executeFolioToolCall("suggest_changes", args, bridge, { suggestChanges });
// `describeSuggestChangesCapabilities({ ...suggestChanges })` returns the same
// capability text the tool description carries, for reuse in a system prompt.
```
Defaults: every contract type except `commentOnBlock` (use `add_comment`) and
`insertSignatureTable` (direct-only, so never a tracked change); review
metadata optional; 50 operations per call; no version pin. The schema
advertises only the properties the allowed types accept, derived from
`FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE` in `/folio-core`.
The parser is a front door over the contract parser, not a second parser. It
decodes leniently first (a `kind` key read as `type`, an operation supplied as
a JSON string, a property that does not apply to the operation type) and
reports every such step in the result's `normalizations`, then enforces the
caps and the options above, mints ids that stay unique across calls
(`op-<nonce>-<n>`), wraps plain-string `comment`s, and hands the batch to
`parseFolioDocumentOperationBatch`.
With `documentVersion` set, the batch carries `precondition.documentVersion`
and the executor compares it to the bridge's `getDocumentVersion()` before
anything is applied: a mismatch skips every operation with
`documentVersionMismatch`, and a bridge without `getDocumentVersion` refuses
version-pinned batches instead of guessing.
### Host-managed review queue
A host with its own review-queue UX (its own place to store proposed edits
pending approval, distinct from folio's tracked-changes redlines) implements
`FolioAgentBridge` with an `applyDocumentOperations` that enqueues instead of
applying and reports the operations as `queued`. The executor then returns
the same envelope the model gets everywhere else, receipts and skip prose
included, and the host keeps the automatic `precondition` stamping and the
document-version check:
```ts
import { getFolioDocumentOperationReceipts } from "@stll/folio-core/server";
import type { FolioAgentBridge } from "@stll/folio-agents";
const queueBridge: FolioAgentBridge = {
snapshot: () => lastSnapshotShownToTheModel,
getDocumentVersion: () => currentEntityVersionId,
applyDocumentOperations: (batch) => {
const queued = batch.operations.map(({ id }) => ({ id }));
reviewQueue.enqueue(batch.operations);
return {
version: batch.version,
status: "queued",
applied: [],
queued,
skipped: [],
issues: [],
receipts: getFolioDocumentOperationReceipts(batch.operations, queued),
undoHandle: null,
};
},
getComments: () => [],
getChanges: () => [],
replyToComment: () => false,
resolveComment: () => false,
};
```
A surface with no editable document at the moment reports every operation as
skipped with `documentNotEditable` instead. `parseSuggestChangesInput` and
`parseAddCommentInput` remain exported for a host that only needs validation.
## Summarizing changes
Two different questions come up under "what changed":
**1. Pending tracked changes in one document** — what a human reviewer would
see as redlines right now. Use the `read_changes` tool (or
`reviewer.getChanges()` directly) and hand the insertions/deletions to the
model:
```ts
const changes = reviewer.getChanges();
const prompt = `Summarize these pending edits for a reviewer:\n${changes
.map((c) => `${c.type === "insertion" ? "+" : "-"} [${c.blockId}] ${c.text}`)
.join("\n")}`;
```
**2. Between two saved versions** — what changed across two `.docx` buffers,
independent of whether either one has any tracked changes at all. Use
`compareDocxVersions` + `formatVersionDiffForLLM`:
```ts
import { compareDocxVersions, formatVersionDiffForLLM } from "@stll/folio-agents";
const diff = await compareDocxVersions(previousVersionBuffer, currentVersionBuffer);
const prompt = `Summarize what changed between these two document versions:\n${formatVersionDiffForLLM(diff)}`;
// -> feed `prompt` to your model as a normal user/system message.
```
Both recipes compare the AS-ACCEPTED view of a document: any tracked changes
already pending in a buffer count as already applied before the comparison
runs (`compareDocxVersions` parses each buffer through the same
`FolioDocxReviewer` snapshot `read_document` uses). Diffing two versions that
each have their own uncommitted redlines still produces a clean, readable diff
instead of raw markup noise.
`compareDocxVersions` ships as a plain async function, not a tool definition:
a model can describe a tool call, but it can't attach two document buffers to
one — buffers aren't JSON-serializable tool arguments a model could produce.
The natural shape is a host-side tool keyed by version identifiers instead
(e.g. a server tool the model calls with two stored version ids, which your
backend resolves to buffers, diffs, and returns the formatted text for).
To produce a reviewable package from the same pair of buffers, use
`generateRedlineDocx`. Optional package-metadata privacy transforms are applied
to the generated output and returned as a structured report:
```ts
import { generateRedlineDocx } from "@stll/folio-agents";
const result = await generateRedlineDocx(previousVersionBuffer, currentVersionBuffer, {
privacy: { transforms: ["remove-attribution", "remove-timestamps"] },
});
await storeGeneratedPackage(result.buffer);
console.log(result.privacyReport);
```
## TanStack AI
TanStack AI's `toolDefinition` accepts a raw JSON Schema object as
`inputSchema`, so the definitions plug in without any wrapper:
```ts
import { toolDefinition } from "@tanstack/ai";
import { getFolioToolDefinitions } from "@stll/folio-agents";
const defs = getFolioToolDefinitions().map((def) =>
toolDefinition({
name: def.name,
description: def.description,
inputSchema: def.inputSchema,
}),
);
// Client-executed tools: run executeFolioToolCall(name, args, bridge) where the
// live editor lives and report the payload back via your chat client's
// addToolResult; server-executed tools: chain .server((args) => ...) instead.
```
The schemas stay within a conservative JSON Schema subset (`type: "object"`,
`properties`, `required`, `enum`, `additionalProperties: false`, plain arrays).
Some providers (e.g. Gemini's OpenAPI-3.0 subset) reject less common keywords;
if your stack projects tool schemas through a provider-safe filter, these
definitions pass through it unchanged.
## Vercel AI SDK
This package ships no `ai` dependency; map its tool definitions with the AI
SDK's own `jsonSchema()` / `tool()` helpers:
```ts
import { jsonSchema, tool } from "ai";
import { executeFolioToolCallUntyped, getFolioToolDefinitions } from "@stll/folio-agents";
const tools = Object.fromEntries(
getFolioToolDefinitions().map((def) => [
def.name,
tool({
description: def.description,
inputSchema: jsonSchema(def.inputSchema),
execute: async (input) => executeFolioToolCallUntyped(def.name, input, bridge),
}),
]),
);
```
## Acknowledgements
folio began as a private fork of [Eigenpal](https://eigenpal.com)'s
[docx-editor](https://github.com/eigenpal/docx-editor). The original license
and copyright are preserved in
[`NOTICE.md`](https://github.com/stella/folio/blob/main/packages/core/NOTICE.md).
## License
Apache-2.0