🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@stll/folio-agents

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@stll/folio-agents - npm Package Compare versions

Comparing version
0.1.1
to
0.2.0
+7
-6
dist/bridge.d.ts
import { FolioAgentChange, FolioAgentComment } from "./types.js";
import { FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditSnapshot } from "@stll/folio-core/server";
import { FolioAIEditSnapshot, FolioDocumentOperationBatch, FolioDocumentOperationResult, FolioDocumentStory, FolioDocumentStoryHandle } from "@stll/folio-core/server";

@@ -21,9 +21,10 @@ //#region src/bridge.d.ts

/**
* Apply operations against the current document. The bridge decides mode
* (tracked-changes by default) and author internally — callers only supply
* the operations.
* Apply a versioned operation batch against the current document. The
* bridge decides mode (tracked-changes by default) and author internally.
*/
applyOperations(operations: FolioAIEditOperation[]): FolioAIEditApplyResult; /** The comment threads present in the document. */
applyDocumentOperations(batch: FolioDocumentOperationBatch): FolioDocumentOperationResult; /** The comment threads present in the document. */
getComments(): FolioAgentComment[]; /** The pending tracked changes (insertions/deletions) present in the document. */
getChanges(): FolioAgentChange[]; /** Reply to a comment thread. Returns `false` when the target comment does not exist. */
getChanges(): FolioAgentChange[]; /** Discover typed document stories when the surface exposes package parts. */
listStories?(): FolioDocumentStory[]; /** Read one previously discovered story. */
readStory?(handle: FolioDocumentStoryHandle): FolioDocumentStory | null; /** Reply to a comment thread. Returns `false` when the target comment does not exist. */
replyToComment(commentId: string, text: string): boolean; /** Mark a comment thread resolved or reopen it. Returns `false` when the target comment does not exist. */

@@ -30,0 +31,0 @@ resolveComment(commentId: string, resolved: boolean): boolean; /** Scroll the live editor to the given block and select it. */

import { FolioAgentBridge } from "../bridge.js";
import { FolioAIEditApplyMode, FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditSnapshot } from "@stll/folio-core/server";
import { FolioAIEditApplyMode, FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditSnapshot, FolioDocumentOperationBatch, FolioDocumentOperationResult } from "@stll/folio-core/server";
import { FolioCommentAnchor, FolioReviewChange as FolioReviewChange$1 } from "@stll/folio-core/ai-edits";

@@ -7,2 +7,7 @@ import { Comment } from "@stll/folio-core/types/content";

//#region src/bridges/editor-ref.d.ts
type FolioAgentEditorApplyDocumentOperationsOptions = {
snapshot: FolioAIEditSnapshot;
batch: FolioDocumentOperationBatch;
author?: string;
};
/**

@@ -28,3 +33,4 @@ * Minimal structural slice of `DocxEditorRef` (`packages/react`) this bridge

author?: string;
}): FolioAIEditApplyResult; /** `DocxEditorRef.scrollToBlock`. */
}): FolioAIEditApplyResult; /** `DocxEditorRef.applyDocumentOperations`, when available on newer refs. */
applyDocumentOperations?(options: FolioAgentEditorApplyDocumentOperationsOptions): FolioDocumentOperationResult; /** `DocxEditorRef.scrollToBlock`. */
scrollToBlock(blockId: string, snapshot?: FolioAIEditSnapshot): boolean; /** `DocxEditorRef.getTotalPages`. */

@@ -92,2 +98,2 @@ getTotalPages(): number;

//#endregion
export { CreateEditorRefBridgeOptions, FolioAgentEditorRefLike, createEditorRefBridge };
export { CreateEditorRefBridgeOptions, FolioAgentEditorApplyDocumentOperationsOptions, FolioAgentEditorRefLike, createEditorRefBridge };
import { toAgentChange } from "./shared.js";
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, assertSupportedFolioDocumentOperationVersion, getFolioDocumentOperationIssues, getFolioDocumentOperationReceipts } from "@stll/folio-core/server";
import { createReply } from "@stll/folio-core/docx/replyToComment";

@@ -61,8 +62,63 @@ //#region src/bridges/editor-ref.ts

snapshot: requireSnapshot,
applyOperations: (operations) => ref.applyAIEditOperations({
snapshot: requireSnapshot(),
operations,
mode,
author
}),
applyDocumentOperations: (batch) => {
assertSupportedFolioDocumentOperationVersion(batch.version);
const snapshot = requireSnapshot();
const versionedBatch = {
...batch,
mode
};
if (ref.applyDocumentOperations) {
const result = ref.applyDocumentOperations({
snapshot,
batch: versionedBatch,
author
});
return {
...result,
issues: result.issues ?? getFolioDocumentOperationIssues(versionedBatch.operations, result.skipped),
receipts: result.receipts ?? getFolioDocumentOperationReceipts(versionedBatch.operations, result.applied)
};
}
if (versionedBatch.dryRun === true) {
const skipped = versionedBatch.operations.map(({ id }) => ({
id,
reason: "unsupportedMode"
}));
return {
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
status: "previewed",
applied: [],
skipped,
issues: getFolioDocumentOperationIssues(versionedBatch.operations, skipped),
receipts: []
};
}
if (versionedBatch.atomic === true) {
const skipped = versionedBatch.operations.map(({ id }) => ({
id,
reason: "unsupportedMode"
}));
return {
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
status: "rejected",
applied: [],
skipped,
issues: getFolioDocumentOperationIssues(versionedBatch.operations, skipped),
receipts: []
};
}
const result = ref.applyAIEditOperations({
snapshot,
operations: versionedBatch.operations,
mode: versionedBatch.mode,
author
});
return {
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
status: "committed",
...result,
issues: getFolioDocumentOperationIssues(versionedBatch.operations, result.skipped),
receipts: getFolioDocumentOperationReceipts(versionedBatch.operations, result.applied)
};
},
getComments: () => {

@@ -121,6 +177,3 @@ const comments = getComments();

found = true;
return {
...comment,
done: resolved
};
return Object.assign({}, comment, { done: resolved });
});

@@ -127,0 +180,0 @@ if (!found) return false;

@@ -36,5 +36,10 @@ import { toAgentChange } from "./shared.js";

snapshot: () => reviewer.snapshot(),
applyOperations: (operations) => reviewer.applyOperations(operations, { mode }),
applyDocumentOperations: (batch) => reviewer.applyDocumentOperations({
...batch,
mode
}),
getComments: () => reviewer.getComments().map(toAgentComment),
getChanges: () => reviewer.getChanges().map(toAgentChange),
listStories: () => reviewer.listStories(),
readStory: (handle) => reviewer.readStory(handle),
replyToComment: (commentId, text) => {

@@ -41,0 +46,0 @@ const parentId = parseCommentId(commentId);

@@ -1,53 +0,15 @@

import { WordDiffSegment } from "@stll/folio-core/ai-edits";
import { FolioBlockDiff, FolioVersionDiff, FolioVersionDiffSegment } from "@stll/folio-core/server";
//#region src/compare.d.ts
/** One word-level diff segment within a `modified` block. Mirrors {@link WordDiffSegment}. */
type FolioAgentVersionDiffSegment = WordDiffSegment;
/** One block-level change between two document versions, in revised-side document order. */
type FolioAgentBlockDiff = {
type: "added";
blockId: string;
kind: string;
text: string;
} | {
type: "deleted";
blockId: string;
kind: string;
text: string;
} | {
type: "modified";
blockId: string;
kind: string;
segments: FolioAgentVersionDiffSegment[];
};
/** Result of {@link compareDocxVersions}. */
type FolioAgentVersionDiff = {
/** Every added, deleted, or modified block, in revised-side document order (deletions slotted where they sat). */changes: FolioAgentBlockDiff[]; /** Counts across every paired/unpaired block, including the unchanged blocks `changes` omits. */
summaryCounts: {
added: number;
deleted: number;
modified: number;
unchanged: number;
};
};
/** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
declare const exceedsLcsBudget: (unpairedBaseCount: number, unpairedRevisedCount: number) => boolean;
/**
* Compare two `.docx` buffers and return a structured, block-level diff.
* See the module doc comment for the as-accepted comparison semantics and
* the three-pass alignment algorithm.
*/
/** Backward-compatible name for a core word-level version-diff segment. */
type FolioAgentVersionDiffSegment = FolioVersionDiffSegment;
/** Backward-compatible name for a core block-level version diff. */
type FolioAgentBlockDiff = FolioBlockDiff;
/** Backward-compatible name for a structured core version diff. */
type FolioAgentVersionDiff = FolioVersionDiff;
/** Compare two `.docx` buffers using folio-core's version comparison semantics. */
declare const compareDocxVersions: (base: ArrayBuffer, revised: ArrayBuffer) => Promise<FolioAgentVersionDiff>;
/**
* Render a {@link FolioAgentVersionDiff} as compact, deterministic text for a
* model prompt: a header line with the summary counts, then one line per
* change — `~ [blockId] modified: …`, `+ [blockId] added: …`,
* `- [blockId] deleted: …`. A modified block's segments render inline using
* `git diff --word-diff` porcelain conventions (`[-removed-]`, `{+added+}`,
* plain text for unchanged runs, truncated to ~30 characters on each side
* when long). The format is stable across calls — do not change it without
* checking `compare.test.ts`.
*/
/** Render a structured version diff as compact, deterministic model input. */
declare const formatVersionDiffForLLM: (diff: FolioAgentVersionDiff) => string;
//#endregion
export { FolioAgentBlockDiff, FolioAgentVersionDiff, FolioAgentVersionDiffSegment, compareDocxVersions, exceedsLcsBudget, formatVersionDiffForLLM };
export { FolioAgentBlockDiff, FolioAgentVersionDiff, FolioAgentVersionDiffSegment, compareDocxVersions, formatVersionDiffForLLM };

@@ -1,162 +0,7 @@

import { diffWordSegments } from "@stll/folio-core/ai-edits";
import { FolioDocxReviewer, getFolioParaIdFromBlockId } from "@stll/folio-core/server";
import { compareDocxVersions as compareDocxVersions$1 } from "@stll/folio-core/server";
//#region src/compare.ts
/**
* Document version-diff engine: compare two `.docx` buffers block by block
* and produce a structured, LLM-summarizable diff.
*
* Both buffers are parsed through {@link FolioDocxReviewer} — the same
* headless parsing + clean-text path `read_document` / `read_changes` use —
* so the comparison runs over each document's AS-ACCEPTED view: any pending
* tracked changes already present in EITHER buffer count as applied before
* the two are compared. Two documents that agree once their own pending
* redlines are accepted report as unchanged, even if the underlying
* tracked-change history differs.
*
* ## Alignment
*
* Blocks are paired across the two snapshots in three passes, each only
* considering blocks the previous pass left unpaired:
*
* 1. **Stable-id pairing.** Blocks whose ids are equal AND not a `seq-NNNN`
* positional fallback ({@link getFolioParaIdFromBlockId} returns non-null)
* are paired directly. This covers both id shapes a snapshot can carry:
* - A real Word `w14:paraId`: stable identity, independent of text — an
* equal-id pair with different text is a genuine edit (`modified`).
* - `FolioDocxReviewer`'s deterministic fallback id (assigned when the
* source paragraph has no `w14:paraId`), which hashes the paragraph's
* TEXT plus its document ordinal. It is structurally indistinguishable
* from a real paraId ({@link getFolioParaIdFromBlockId} can't tell them
* apart), but pairing on equality is still safe: two equal deterministic
* ids necessarily came from identical text at an identical ordinal, so
* the pair is always text-equal (`unchanged`) — never a false
* `modified`. What it can't do is FIND a paragraph whose ordinal shifted
* (an insertion/deletion earlier in the document) even though its text
* is unchanged: that pair has two different fallback ids and falls
* through to pass 2.
* 2. **Exact-text pairing.** An order-preserving LCS over remaining blocks,
* matched by exact text equality. This is what recovers same-text blocks
* that pass 1 missed because a fallback id shifted with the ordinal. Its
* O(m·n) table is skipped ({@link exceedsLcsBudget}) once the unpaired
* counts on both sides would exceed a fixed cell budget, so a document
* with few/no stable ids can't force a quadratic-sized allocation; those
* blocks fall through to pass 3 instead.
* 3. **Positional fallback.** Whatever a monotonicity filter leaves
* unpaired is split into the gaps between anchored pairs (pass 1 + 2,
* time-ordered); within each gap the shorter side is zipped positionally
* against the longer one (`modified`), and any excess on either side is
* reported as `added` / `deleted`.
*
* The combined anchor set from passes 1 and 2 is re-filtered to the longest
* increasing subsequence by revised-side index before pass 3 runs, so a
* pathological crossing match (content reordered across versions) can't
* produce an out-of-order gap — the alignment always walks both documents
* forward.
*/
const isStableBlockId = (id) => getFolioParaIdFromBlockId(id) !== null;
const index = (blocks) => blocks.map((block, blockIndex) => ({
block,
index: blockIndex
}));
/**
* Longest increasing subsequence by `revisedIndex`, assuming `pairs` is
* already sorted by `baseIndex` ascending. Drops any pair that would make
* the alignment walk backward in the revised document — the guard against
* both id collisions (pass 1) and any crossing match (pass 1 + 2 combined).
*/
const longestIncreasingByRevisedIndex = (pairs) => {
if (pairs.length === 0) return [];
const lengths = Array.from({ length: pairs.length }).fill(1);
const predecessors = Array.from({ length: pairs.length }).fill(-1);
let bestEnd = 0;
for (let i = 0; i < pairs.length; i++) {
for (let j = 0; j < i; j++) {
const current = pairs[j];
const candidate = pairs[i];
if (!current || !candidate) continue;
if (current.revisedIndex < candidate.revisedIndex && (lengths[j] ?? 0) + 1 > (lengths[i] ?? 0)) {
lengths[i] = (lengths[j] ?? 0) + 1;
predecessors[i] = j;
}
}
if ((lengths[i] ?? 0) > (lengths[bestEnd] ?? 0)) bestEnd = i;
}
const ordered = [];
for (let cursor = bestEnd; cursor !== -1; cursor = predecessors[cursor] ?? -1) {
const pair = pairs[cursor];
if (pair) ordered.push(pair);
}
return ordered.toReversed();
};
/** Pass 1: pair blocks with equal, non-`seq-NNNN` ids. See the module doc comment. */
const pairByStableId = (base, revised) => {
const revisedIndexById = /* @__PURE__ */ new Map();
revised.forEach((block, revisedIndex) => {
if (isStableBlockId(block.id)) revisedIndexById.set(block.id, revisedIndex);
});
const candidates = [];
base.forEach((block, baseIndex) => {
if (!isStableBlockId(block.id)) return;
const revisedIndex = revisedIndexById.get(block.id);
if (revisedIndex !== void 0) candidates.push({
baseIndex,
revisedIndex
});
});
return longestIncreasingByRevisedIndex(candidates);
};
/**
* Cell budget for pass 2's O(m·n) exact-text LCS table (`dp` below allocates
* `(m + 1) * (n + 1)` numbers). A document with no `w14:paraId`s — or an
* adversarial one crafted to defeat pass 1 — can leave thousands of blocks
* unpaired on both sides; without a cap, `pairByExactText` would allocate a
* quadratic-sized table for it. Past this budget, {@link exceedsLcsBudget}
* makes pass 2 back off entirely so alignment falls through to pass 3's
* linear positional zip instead — pairing is less precise for these
* degenerate inputs, but memory use stays bounded.
*/
const MAX_LCS_CELLS = 4e6;
/** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
const exceedsLcsBudget = (unpairedBaseCount, unpairedRevisedCount) => unpairedBaseCount * unpairedRevisedCount > MAX_LCS_CELLS;
/** Pass 2: order-preserving LCS by exact text equality over the blocks pass 1 left unpaired. */
const pairByExactText = (base, revised) => {
const m = base.length;
const n = revised.length;
if (m === 0 || n === 0) return [];
if (exceedsLcsBudget(m, n)) return [];
const stride = n + 1;
const dp = new Int32Array((m + 1) * stride);
for (let i = m - 1; i >= 0; i--) {
const rowOffset = i * stride;
const nextRowOffset = (i + 1) * stride;
for (let j = n - 1; j >= 0; j--) {
const baseText = base[i]?.block.text;
const revisedText = revised[j]?.block.text;
dp[rowOffset + j] = baseText === revisedText ? (dp[nextRowOffset + j + 1] ?? 0) + 1 : Math.max(dp[nextRowOffset + j] ?? 0, dp[rowOffset + j + 1] ?? 0);
}
}
const pairs = [];
let i = 0;
let j = 0;
while (i < m && j < n) {
const baseEntry = base[i];
const revisedEntry = revised[j];
if (!baseEntry || !revisedEntry) break;
if (baseEntry.block.text === revisedEntry.block.text) {
pairs.push({
baseIndex: baseEntry.index,
revisedIndex: revisedEntry.index
});
i++;
j++;
continue;
}
if ((dp[(i + 1) * stride + j] ?? 0) >= (dp[i * stride + j + 1] ?? 0)) i++;
else j++;
}
return pairs;
};
/** Compare two `.docx` buffers using folio-core's version comparison semantics. */
const compareDocxVersions = (base, revised) => compareDocxVersions$1(base, revised);
const ELLIPSIS = "…";
const UNCHANGED_EDGE_CHARS = 30;
/** Collapse a long `equal`-type run to ~30 chars on each side with an ellipsis in between. */
const truncateUnchangedRun = (text) => {

@@ -166,3 +11,2 @@ if (text.length <= 61) return text;

};
/** Render one word-diff segment: plain for `equal` (truncated if long), bracketed for `del` / `ins`. */
const renderSegment = (segment) => {

@@ -177,111 +21,18 @@ switch (segment.type) {

const formatChangeLine = (change) => {
if (change.type === "added") return `+ [${change.blockId}] added: ${change.text}`;
if (change.type === "deleted") return `- [${change.blockId}] deleted: ${change.text}`;
return `~ [${change.blockId}] modified: ${change.segments.map(renderSegment).join("")}`;
};
/**
* Compare two `.docx` buffers and return a structured, block-level diff.
* See the module doc comment for the as-accepted comparison semantics and
* the three-pass alignment algorithm.
*/
const compareDocxVersions = async (base, revised) => {
const [baseReviewer, revisedReviewer] = await Promise.all([FolioDocxReviewer.fromBuffer(base), FolioDocxReviewer.fromBuffer(revised)]);
const baseBlocks = baseReviewer.snapshot().blocks;
const revisedBlocks = revisedReviewer.snapshot().blocks;
const stableIdAnchors = pairByStableId(baseBlocks, revisedBlocks);
const usedBaseIndexes = new Set(stableIdAnchors.map((anchor) => anchor.baseIndex));
const usedRevisedIndexes = new Set(stableIdAnchors.map((anchor) => anchor.revisedIndex));
const exactTextAnchors = pairByExactText(index(baseBlocks).filter(({ index: i }) => !usedBaseIndexes.has(i)), index(revisedBlocks).filter(({ index: i }) => !usedRevisedIndexes.has(i)));
const anchors = longestIncreasingByRevisedIndex([...stableIdAnchors, ...exactTextAnchors].toSorted((a, b) => a.baseIndex - b.baseIndex));
const changes = [];
const counts = {
added: 0,
deleted: 0,
modified: 0,
unchanged: 0
};
/** Pass 3: positionally zip the leftover blocks in one gap between anchors. */
const emitGap = (baseFrom, baseTo, revisedFrom, revisedTo) => {
const baseSlice = baseBlocks.slice(baseFrom, baseTo);
const revisedSlice = revisedBlocks.slice(revisedFrom, revisedTo);
const pairedCount = Math.min(baseSlice.length, revisedSlice.length);
for (let k = 0; k < pairedCount; k++) {
const baseBlock = baseSlice[k];
const revisedBlock = revisedSlice[k];
if (!baseBlock || !revisedBlock) continue;
if (baseBlock.text === revisedBlock.text) {
counts.unchanged++;
continue;
}
counts.modified++;
changes.push({
type: "modified",
blockId: revisedBlock.id,
kind: revisedBlock.kind,
segments: diffWordSegments(baseBlock.text, revisedBlock.text)
});
}
for (let k = pairedCount; k < baseSlice.length; k++) {
const baseBlock = baseSlice[k];
if (!baseBlock) continue;
counts.deleted++;
changes.push({
type: "deleted",
blockId: baseBlock.id,
kind: baseBlock.kind,
text: baseBlock.text
});
}
for (let k = pairedCount; k < revisedSlice.length; k++) {
const revisedBlock = revisedSlice[k];
if (!revisedBlock) continue;
counts.added++;
changes.push({
type: "added",
blockId: revisedBlock.id,
kind: revisedBlock.kind,
text: revisedBlock.text
});
}
};
let baseCursor = 0;
let revisedCursor = 0;
for (const anchor of anchors) {
emitGap(baseCursor, anchor.baseIndex, revisedCursor, anchor.revisedIndex);
const baseBlock = baseBlocks[anchor.baseIndex];
const revisedBlock = revisedBlocks[anchor.revisedIndex];
if (baseBlock && revisedBlock) if (baseBlock.text === revisedBlock.text) counts.unchanged++;
else {
counts.modified++;
changes.push({
type: "modified",
blockId: revisedBlock.id,
kind: revisedBlock.kind,
segments: diffWordSegments(baseBlock.text, revisedBlock.text)
});
}
baseCursor = anchor.baseIndex + 1;
revisedCursor = anchor.revisedIndex + 1;
switch (change.type) {
case "added": return `+ [${change.blockId}] added: ${change.text}`;
case "deleted": return `- [${change.blockId}] deleted: ${change.text}`;
case "modified": return `~ [${change.blockId}] modified: ${change.segments.map(renderSegment).join("")}`;
case "formatChanged": return `~ [${change.blockId}] format changed (${change.changedProperties.join(", ")}): ${truncateUnchangedRun(change.text)}`;
case "movedFrom": return `< [${change.blockId}] moved away (move ${change.moveGroupId}): ${truncateUnchangedRun(change.text)}`;
case "movedTo": return `> [${change.blockId}] moved here (move ${change.moveGroupId}): ${truncateUnchangedRun(change.text)}`;
default: return "";
}
emitGap(baseCursor, baseBlocks.length, revisedCursor, revisedBlocks.length);
return {
changes,
summaryCounts: counts
};
};
/**
* Render a {@link FolioAgentVersionDiff} as compact, deterministic text for a
* model prompt: a header line with the summary counts, then one line per
* change — `~ [blockId] modified: …`, `+ [blockId] added: …`,
* `- [blockId] deleted: …`. A modified block's segments render inline using
* `git diff --word-diff` porcelain conventions (`[-removed-]`, `{+added+}`,
* plain text for unchanged runs, truncated to ~30 characters on each side
* when long). The format is stable across calls — do not change it without
* checking `compare.test.ts`.
*/
/** Render a structured version diff as compact, deterministic model input. */
const formatVersionDiffForLLM = (diff) => {
const { added, deleted, modified, unchanged } = diff.summaryCounts;
return [`Version diff: ${added} added, ${deleted} deleted, ${modified} modified, ${unchanged} unchanged`, ...diff.changes.map(formatChangeLine)].join("\n");
const { added, deleted, modified, formatChanged, moved, unchanged } = diff.summaryCounts;
return [`Version diff: ${added} added, ${deleted} deleted, ${modified} modified, ${formatChanged} format-changed, ${moved} moved, ${unchanged} unchanged`, ...diff.changes.map(formatChangeLine)].join("\n");
};
//#endregion
export { compareDocxVersions, exceedsLcsBudget, formatVersionDiffForLLM };
export { compareDocxVersions, formatVersionDiffForLLM };
import { explainTextTooLong, parseAddCommentInput, parseSuggestChangesInput } from "./parse.js";
import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, createFolioAITextRangeHandle } from "@stll/folio-core/server";
//#region src/execute.ts

@@ -40,2 +41,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);

if (name === FOLIO_AGENT_TOOL_NAMES.readDocument) return readDocument(bridge);
if (name === FOLIO_AGENT_TOOL_NAMES.listStories) return bridge.listStories ? ok(bridge.listStories()) : fail("This editor surface does not support story discovery.");
if (name === FOLIO_AGENT_TOOL_NAMES.readStory) return readStory(args, bridge);
if (name === FOLIO_AGENT_TOOL_NAMES.findText) return findText(args, bridge);

@@ -61,2 +64,27 @@ if (name === FOLIO_AGENT_TOOL_NAMES.readComments) return readComments(args, bridge);

};
const readStory = (args, bridge) => {
if (!bridge.readStory) return fail("This editor surface does not support story reads.");
if (!isPlainObject(args) || !isPlainObject(args["handle"])) return fail("read_story requires a typed `handle` from list_stories.");
const handle = args["handle"];
const type = handle["type"];
let parsed;
if (type === "main") parsed = { type };
else if (type === "header" || type === "footer") {
const relationshipId = handle["relationshipId"];
if (!isNonEmptyString(relationshipId)) return fail("read_story requires the handle's `relationshipId`.");
parsed = {
type,
relationshipId
};
} else if (type === "footnote" || type === "endnote") {
const noteId = handle["noteId"];
if (typeof noteId !== "number" || !Number.isInteger(noteId)) return fail("read_story requires the handle's integer `noteId`.");
parsed = {
type,
noteId
};
} else return fail("read_story received an unsupported story handle type.");
const story = bridge.readStory(parsed);
return story ? ok(story) : fail("The requested story was not found; run list_stories again.");
};
const CONTEXT_RADIUS = 40;

@@ -78,4 +106,12 @@ const findTextMatches = (blocks, query, matchCase) => {

const contextEnd = Math.min(block.text.length, at + query.length + CONTEXT_RADIUS);
const range = createFolioAITextRangeHandle({
blockId: block.id,
text: block.text,
startOffset: at,
endOffset: at + query.length
});
if (range === null) break;
matches.push({
blockId: block.id,
range,
occurrenceInBlock: occurrence,

@@ -103,7 +139,8 @@ context: block.text.slice(contextStart, contextEnd)

const { matches, totalMatches } = findTextMatches(blocks, query, matchCase === true);
return ok({
const result = {
matches,
truncated: totalMatches > matches.length,
totalMatches
});
};
return ok(result);
};

@@ -129,2 +166,6 @@ const isCommentFilter = (value) => value === "all" || value === "open" || value === "resolved";

if (reason === "unsupportedBlock") return "this block kind does not support this operation.";
if (reason === "unsupportedMode") return "this operation does not support the requested mutation mode; inspect document operation capabilities and retry with a supported mode.";
if (reason === "atomicBatchRejected") return "another operation in this atomic batch could not be applied; no operations were committed.";
if (reason === "preconditionFailed") return "the target block changed after the operation was prepared; re-read the document and retry with a fresh operation.";
if (reason === "staleRange") return "the selected range changed or shifted; run find_text again and retry with the fresh range.";
if (reason === "emptyOperation") return "this operation has no effect; nothing to apply.";

@@ -135,2 +176,3 @@ if (reason === "noopOperation") return "this operation would not change the document (the text already matches what you asked for).";

const summarizeApplyResult = (result) => ({
version: result.version,
applied: result.applied.map((entry) => ({ id: entry.id })),

@@ -140,8 +182,26 @@ skipped: result.skipped.map((entry) => ({

reason: explainSkipReason(entry.reason)
}))
})),
issues: result.issues ?? [],
receipts: result.receipts ?? []
});
const applyOperations = (bridge, operations) => {
const snapshot = bridge.snapshot();
const guardedOperations = [];
for (const operation of operations) {
const blockId = operation.type === "replaceRange" || operation.type === "commentOnRange" || operation.type === "formatRange" ? operation.range.blockId : operation.blockId;
const blockTextHash = snapshot.anchors[blockId]?.textHash;
guardedOperations.push({
...operation,
...blockTextHash !== void 0 && { precondition: { blockTextHash } }
});
}
return summarizeApplyResult(bridge.applyDocumentOperations({
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
operations: guardedOperations
}));
};
const addComment = (args, bridge) => {
const parsed = parseAddCommentInput(args);
if (!parsed.ok) return fail(parsed.error);
return ok(summarizeApplyResult(bridge.applyOperations([parsed.operation])));
return ok(applyOperations(bridge, [parsed.operation]));
};

@@ -151,3 +211,3 @@ const suggestChanges = (args, bridge) => {

if (!parsed.ok) return fail(parsed.error);
return ok(summarizeApplyResult(bridge.applyOperations(parsed.operations)));
return ok(applyOperations(bridge, parsed.operations));
};

@@ -154,0 +214,0 @@ const replyComment = (args, bridge) => {

import { FOLIO_AGENT_TOOL_NAMES, FolioAgentApplyOperationsSummary, FolioAgentBlock, FolioAgentChange, FolioAgentComment, FolioAgentCommentFilter, FolioAgentCommentReply, FolioAgentTextMatch, FolioAgentToolDefinition, FolioAgentToolName, FolioToolCallResult } from "./types.js";
import { FolioAgentBridge } from "./bridge.js";
import { CreateEditorRefBridgeOptions, FolioAgentEditorRefLike, createEditorRefBridge } from "./bridges/editor-ref.js";
import { CreateEditorRefBridgeOptions, FolioAgentEditorApplyDocumentOperationsOptions, FolioAgentEditorRefLike, createEditorRefBridge } from "./bridges/editor-ref.js";
import { CreateReviewerBridgeOptions, createReviewerBridge } from "./bridges/reviewer.js";

@@ -10,2 +10,2 @@ import { FolioAgentBlockDiff, FolioAgentVersionDiff, FolioAgentVersionDiffSegment, compareDocxVersions, formatVersionDiffForLLM } from "./compare.js";

import { FOLIO_AGENT_TOOLS, getFolioToolDefinitions } from "./tools.js";
export { type AnthropicToolDefinition, type CreateEditorRefBridgeOptions, type CreateReviewerBridgeOptions, FOLIO_AGENT_TOOLS, FOLIO_AGENT_TOOL_NAMES, type FolioAgentApplyOperationsSummary, type FolioAgentBlock, type FolioAgentBlockDiff, type FolioAgentBridge, type FolioAgentChange, type FolioAgentComment, type FolioAgentCommentFilter, type FolioAgentCommentReply, type FolioAgentEditorRefLike, type FolioAgentTextMatch, type FolioAgentToolDefinition, type FolioAgentToolName, type FolioAgentVersionDiff, type FolioAgentVersionDiffSegment, type FolioToolCallResult, type OpenAIToolDefinition, type ParseAddCommentResult, type ParseSuggestChangesResult, compareDocxVersions, createEditorRefBridge, createReviewerBridge, executeFolioToolCall, formatVersionDiffForLLM, getFolioToolDefinitions, parseAddCommentInput, parseSuggestChangesInput, toAnthropicTools, toOpenAITools };
export { type AnthropicToolDefinition, type CreateEditorRefBridgeOptions, type CreateReviewerBridgeOptions, FOLIO_AGENT_TOOLS, FOLIO_AGENT_TOOL_NAMES, type FolioAgentApplyOperationsSummary, type FolioAgentBlock, type FolioAgentBlockDiff, type FolioAgentBridge, type FolioAgentChange, type FolioAgentComment, type FolioAgentCommentFilter, type FolioAgentCommentReply, type FolioAgentEditorApplyDocumentOperationsOptions, type FolioAgentEditorRefLike, type FolioAgentTextMatch, type FolioAgentToolDefinition, type FolioAgentToolName, type FolioAgentVersionDiff, type FolioAgentVersionDiffSegment, type FolioToolCallResult, type OpenAIToolDefinition, type ParseAddCommentResult, type ParseSuggestChangesResult, compareDocxVersions, createEditorRefBridge, createReviewerBridge, executeFolioToolCall, formatVersionDiffForLLM, getFolioToolDefinitions, parseAddCommentInput, parseSuggestChangesInput, toAnthropicTools, toOpenAITools };

@@ -60,4 +60,27 @@ //#region src/parse.ts

};
const OPERATION_TYPES = "replaceInBlock, insertAfterBlock, insertBeforeBlock, replaceBlock, deleteBlock";
const isOperationType = (value) => value === "replaceInBlock" || value === "insertAfterBlock" || value === "insertBeforeBlock" || value === "replaceBlock" || value === "deleteBlock";
const OPERATION_TYPES = "replaceInBlock, replaceRange, commentOnRange, insertAfterBlock, insertBeforeBlock, replaceBlock, deleteBlock";
const isOperationType = (value) => value === "replaceInBlock" || value === "replaceRange" || value === "commentOnRange" || value === "insertAfterBlock" || value === "insertBeforeBlock" || value === "replaceBlock" || value === "deleteBlock";
const readTextRange = (value, index) => {
const path = `operations[${index}].range`;
if (!isPlainObject(value)) return `${path} must be an object copied from find_text.`;
const type = value["type"];
const story = value["story"];
const blockId = value["blockId"];
const startOffset = value["startOffset"];
const endOffset = value["endOffset"];
const selectedTextHash = value["selectedTextHash"];
if (type !== "textRange" || story !== "main") return `${path} must be a main-story textRange copied from find_text.`;
if (!isNonEmptyString(blockId)) return `${path}.blockId must be a non-empty string.`;
if (typeof startOffset !== "number" || !Number.isInteger(startOffset) || startOffset < 0) return `${path}.startOffset must be a non-negative integer.`;
if (typeof endOffset !== "number" || !Number.isInteger(endOffset) || endOffset <= startOffset) return `${path}.endOffset must be an integer greater than startOffset.`;
if (!isNonEmptyString(selectedTextHash) || !/^h[0-9a-z]+$/.test(selectedTextHash)) return `${path}.selectedTextHash must be a normalized text hash.`;
return {
type,
story,
blockId,
startOffset,
endOffset,
selectedTextHash
};
};
/** Validate + map one `suggest_changes` operation, or return a plain-language error string. */

@@ -71,3 +94,2 @@ const buildSuggestedOperation = (raw, index) => {

if (!isOperationType(type)) return `operations[${index}].type must be one of ${OPERATION_TYPES}.`;
if (!isNonEmptyString(blockId)) return `operations[${index}].blockId must be a non-empty string.`;
if (id !== void 0 && !isNonEmptyString(id)) return `operations[${index}].id must be a non-empty string when provided.`;

@@ -78,2 +100,26 @@ if (comment !== void 0 && typeof comment !== "string") return `operations[${index}].comment must be a string when provided.`;

const commentField = typeof comment === "string" ? { comment: { text: comment } } : {};
if (type === "replaceRange" || type === "commentOnRange") {
const range = readTextRange(raw["range"], index);
if (typeof range === "string") return range;
if (type === "commentOnRange") {
if (!isNonEmptyString(comment)) return `operations[${index}] (commentOnRange) requires a non-empty string \`comment\`.`;
return {
id: opId,
type,
range,
comment: { text: comment }
};
}
const replace = raw["replace"];
if (typeof replace !== "string") return `operations[${index}] (replaceRange) requires a string \`replace\`.`;
if (replace.length > 1e5) return explainTextTooLong(`operations[${index}] (replaceRange) \`replace\``, replace.length);
return {
id: opId,
type,
range,
replace,
...commentField
};
}
if (!isNonEmptyString(blockId)) return `operations[${index}].blockId must be a non-empty string.`;
if (type === "replaceInBlock") {

@@ -80,0 +126,0 @@ const find = raw["find"];

@@ -22,2 +22,40 @@ import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";

{
name: FOLIO_AGENT_TOOL_NAMES.listStories,
description: "List readable document stories and their typed handles.",
inputSchema: {
type: "object",
properties: {},
required: [],
additionalProperties: false
}
},
{
name: FOLIO_AGENT_TOOL_NAMES.readStory,
description: "Read one document story using a handle returned by `list_stories`.",
inputSchema: {
type: "object",
properties: { handle: {
type: "object",
properties: {
type: {
type: "string",
enum: [
"main",
"header",
"footer",
"footnote",
"endnote"
]
},
relationshipId: { type: "string" },
noteId: { type: "integer" }
},
required: ["type"],
additionalProperties: false
} },
required: ["handle"],
additionalProperties: false
}
},
{
name: FOLIO_AGENT_TOOL_NAMES.findText,

@@ -111,2 +149,4 @@ description: "Search the document body for a text string and return `{ matches, truncated, totalMatches }`, where each match has its block id, which occurrence within that block it is, and surrounding context. `matches` is capped at 200 entries; `truncated` is true and `totalMatches` reports the real count when there were more — narrow the query instead of assuming you saw every hit. Use this to locate the blockId for a known phrase instead of reading the whole document.",

"replaceInBlock",
"replaceRange",
"commentOnRange",
"insertAfterBlock",

@@ -123,2 +163,35 @@ "insertBeforeBlock",

},
range: {
type: "object",
description: "Required for `replaceRange`: copy the range object returned by `find_text`.",
properties: {
type: {
type: "string",
enum: ["textRange"]
},
story: {
type: "string",
enum: ["main"]
},
blockId: { type: "string" },
startOffset: {
type: "integer",
minimum: 0
},
endOffset: {
type: "integer",
minimum: 1
},
selectedTextHash: { type: "string" }
},
required: [
"type",
"story",
"blockId",
"startOffset",
"endOffset",
"selectedTextHash"
],
additionalProperties: false
},
find: {

@@ -130,3 +203,3 @@ type: "string",

type: "string",
description: "Required for `replaceInBlock`: the text to replace `find` with, up to 100,000 characters."
description: "Required for `replaceInBlock` and `replaceRange`: replacement text, up to 100,000 characters."
},

@@ -142,3 +215,3 @@ text: {

},
required: ["type", "blockId"],
required: ["type"],
additionalProperties: false

@@ -145,0 +218,0 @@ }

@@ -0,1 +1,3 @@

import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FolioAITextRangeHandle, FolioDocumentOperationIssue, FolioDocumentOperationReceipt } from "@stll/folio-core/server";
//#region src/types.d.ts

@@ -11,2 +13,4 @@ /**

readonly readDocument: "read_document";
readonly listStories: "list_stories";
readonly readStory: "read_story";
readonly findText: "find_text";

@@ -58,3 +62,4 @@ readonly readComments: "read_comments";

type FolioAgentTextMatch = {
blockId: string; /** 0-based index of this occurrence within its block (multiple matches in one block increment this). */
blockId: string; /** Stable handle that can be passed directly to a `replaceRange` operation. */
range: FolioAITextRangeHandle; /** 0-based index of this occurrence within its block (multiple matches in one block increment this). */
occurrenceInBlock: number; /** The match plus ~40 characters of surrounding context on each side. */

@@ -108,2 +113,3 @@ context: string;

type FolioAgentApplyOperationsSummary = {
version: typeof FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION;
applied: {

@@ -116,4 +122,6 @@ id: string;

}[];
issues: FolioDocumentOperationIssue[];
receipts: FolioDocumentOperationReceipt[];
};
//#endregion
export { FOLIO_AGENT_TOOL_NAMES, FolioAgentApplyOperationsSummary, FolioAgentBlock, FolioAgentChange, FolioAgentComment, FolioAgentCommentFilter, FolioAgentCommentReply, FolioAgentFindTextResult, FolioAgentTextMatch, FolioAgentToolDefinition, FolioAgentToolName, FolioToolCallResult };

@@ -11,2 +11,4 @@ //#region src/types.ts

readDocument: "read_document",
listStories: "list_stories",
readStory: "read_story",
findText: "find_text",

@@ -13,0 +15,0 @@ readComments: "read_comments",

{
"name": "@stll/folio-agents",
"version": "0.1.1",
"version": "0.2.0",
"description": "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.",

@@ -53,7 +53,7 @@ "keywords": [

"pack:dry-run": "bun pm pack --dry-run",
"typecheck": "tsgo --noEmit -p tsconfig.build.json",
"typecheck": "bun ../../scripts/tsc-native.ts --noEmit -p tsconfig.build.json",
"test": "bun test src"
},
"dependencies": {
"@stll/folio-core": "^0.2.0"
"@stll/folio-core": "^0.4.0"
},

@@ -60,0 +60,0 @@ "main": "./dist/index.js",

+16
-15

@@ -21,15 +21,15 @@ # @stll/folio-agents

| Tool | What it does |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `read_document` | Read the document body as `{ blockId, kind, text }` blocks |
| `find_text` | Search block text for a string; returns block id, occurrence, and context per match |
| `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 `replaceInBlock` / `insertAfterBlock` / `insertBeforeBlock` / `replaceBlock` / `deleteBlock` 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) |
| Tool | What it does |
| ----------------- | ----------------------------------------------------------------------------------- |
| `read_document` | Read the document body as `{ blockId, kind, text }` blocks |
| `find_text` | Search block text for a string; returns block id, occurrence, and context per match |
| `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) |

@@ -40,3 +40,4 @@ Block ids and comment ids always come from a prior tool call

operation is skipped (e.g. the block changed since it was last read), so the
model can re-read and retry.
model can re-read and retry. Successful mutation results include `receipts`
that identify affected blocks, ranges, insertions, and created comments.

@@ -120,3 +121,3 @@ ### Untrusted documents

// `parsed.operations` is FolioAIEditOperation[] — route it into your own
// review queue instead of bridge.applyOperations(...).
// review queue instead of bridge.applyDocumentOperations(...).
reviewQueue.enqueue(parsed.operations);

@@ -123,0 +124,0 @@ }

@@ -102,10 +102,14 @@ ---

reissue with fresh ids, not a dead end.
- Successful mutations return input-ordered `receipts`. Use their typed
`affected` targets to identify updated blocks, stable ranges, insertions,
and created comments without inferring effects from document text.
- `suggest_changes` defaults to tracked-changes mode on both shipped bridges
— it proposes redlines for a human to accept or reject, never edits the
visible text directly.
- The tool surface here is fixed: five edit operation kinds
(`replaceInBlock`, `insertAfterBlock`, `insertBeforeBlock`, `replaceBlock`,
`deleteBlock`) plus comment/reply/resolve. There is no
- The tool surface here is fixed: seven edit operation kinds
(`replaceInBlock`, `replaceRange`, `commentOnRange`, `insertAfterBlock`,
`insertBeforeBlock`, `replaceBlock`, `deleteBlock`) plus
comment/reply/resolve. There is no
`insertSignatureTable`-style structural op, and none should be invented —
compose the five primitives, or extend `@stll/folio-core`'s ai-edits engine
compose the seven primitives, or extend `@stll/folio-core`'s ai-edits engine
itself if a document needs a structural operation this package doesn't

@@ -127,4 +131,4 @@ expose.

- **Pending redlines in one document** — what a reviewer would see as tracked
changes right now: use the `read_changes` tool (headless bridge only; see
the capability gap above) or call `reviewer.getChanges()` directly.
changes right now: use the `read_changes` tool on a capable bridge or call
`reviewer.getChanges()` directly.
- **Between two saved `.docx` versions** — `compareDocxVersions(previousBuffer,

@@ -131,0 +135,0 @@ currentBuffer)` + `formatVersionDiffForLLM(diff)`. Both are plain async