@stll/folio-agents 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +225 -0
- package/dist/bridge.d.ts +36 -0
- package/dist/bridge.js +0 -0
- package/dist/bridges/editor-ref.d.ts +90 -0
- package/dist/bridges/editor-ref.js +139 -0
- package/dist/bridges/reviewer.d.ts +19 -0
- package/dist/bridges/reviewer.js +48 -0
- package/dist/bridges/shared.d.ts +16 -0
- package/dist/bridges/shared.js +19 -0
- package/dist/compare.d.ts +53 -0
- package/dist/compare.js +285 -0
- package/dist/execute.d.ts +19 -0
- package/dist/execute.js +188 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/parse.d.ts +45 -0
- package/dist/parse.js +157 -0
- package/dist/providers.d.ts +24 -0
- package/dist/providers.js +18 -0
- package/dist/tools.d.ts +16 -0
- package/dist/tools.js +224 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +23 -0
- package/package.json +61 -1
- package/skills/folio-agents/SKILL.md +145 -0
package/dist/compare.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { diffWordSegments } from "@stll/folio-core/ai-edits";
|
|
2
|
+
import { FolioDocxReviewer, getFolioParaIdFromBlockId } from "@stll/folio-core/server";
|
|
3
|
+
//#region src/compare.ts
|
|
4
|
+
/**
|
|
5
|
+
* Document version-diff engine: compare two `.docx` buffers block by block
|
|
6
|
+
* and produce a structured, LLM-summarizable diff.
|
|
7
|
+
*
|
|
8
|
+
* Both buffers are parsed through {@link FolioDocxReviewer} — the same
|
|
9
|
+
* headless parsing + clean-text path `read_document` / `read_changes` use —
|
|
10
|
+
* so the comparison runs over each document's AS-ACCEPTED view: any pending
|
|
11
|
+
* tracked changes already present in EITHER buffer count as applied before
|
|
12
|
+
* the two are compared. Two documents that agree once their own pending
|
|
13
|
+
* redlines are accepted report as unchanged, even if the underlying
|
|
14
|
+
* tracked-change history differs.
|
|
15
|
+
*
|
|
16
|
+
* ## Alignment
|
|
17
|
+
*
|
|
18
|
+
* Blocks are paired across the two snapshots in three passes, each only
|
|
19
|
+
* considering blocks the previous pass left unpaired:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Stable-id pairing.** Blocks whose ids are equal AND not a `seq-NNNN`
|
|
22
|
+
* positional fallback ({@link getFolioParaIdFromBlockId} returns non-null)
|
|
23
|
+
* are paired directly. This covers both id shapes a snapshot can carry:
|
|
24
|
+
* - A real Word `w14:paraId`: stable identity, independent of text — an
|
|
25
|
+
* equal-id pair with different text is a genuine edit (`modified`).
|
|
26
|
+
* - `FolioDocxReviewer`'s deterministic fallback id (assigned when the
|
|
27
|
+
* source paragraph has no `w14:paraId`), which hashes the paragraph's
|
|
28
|
+
* TEXT plus its document ordinal. It is structurally indistinguishable
|
|
29
|
+
* from a real paraId ({@link getFolioParaIdFromBlockId} can't tell them
|
|
30
|
+
* apart), but pairing on equality is still safe: two equal deterministic
|
|
31
|
+
* ids necessarily came from identical text at an identical ordinal, so
|
|
32
|
+
* the pair is always text-equal (`unchanged`) — never a false
|
|
33
|
+
* `modified`. What it can't do is FIND a paragraph whose ordinal shifted
|
|
34
|
+
* (an insertion/deletion earlier in the document) even though its text
|
|
35
|
+
* is unchanged: that pair has two different fallback ids and falls
|
|
36
|
+
* through to pass 2.
|
|
37
|
+
* 2. **Exact-text pairing.** An order-preserving LCS over remaining blocks,
|
|
38
|
+
* matched by exact text equality. This is what recovers same-text blocks
|
|
39
|
+
* that pass 1 missed because a fallback id shifted with the ordinal. Its
|
|
40
|
+
* O(m·n) table is skipped ({@link exceedsLcsBudget}) once the unpaired
|
|
41
|
+
* counts on both sides would exceed a fixed cell budget, so a document
|
|
42
|
+
* with few/no stable ids can't force a quadratic-sized allocation; those
|
|
43
|
+
* blocks fall through to pass 3 instead.
|
|
44
|
+
* 3. **Positional fallback.** Whatever a monotonicity filter leaves
|
|
45
|
+
* unpaired is split into the gaps between anchored pairs (pass 1 + 2,
|
|
46
|
+
* time-ordered); within each gap the shorter side is zipped positionally
|
|
47
|
+
* against the longer one (`modified`), and any excess on either side is
|
|
48
|
+
* reported as `added` / `deleted`.
|
|
49
|
+
*
|
|
50
|
+
* The combined anchor set from passes 1 and 2 is re-filtered to the longest
|
|
51
|
+
* increasing subsequence by revised-side index before pass 3 runs, so a
|
|
52
|
+
* pathological crossing match (content reordered across versions) can't
|
|
53
|
+
* produce an out-of-order gap — the alignment always walks both documents
|
|
54
|
+
* forward.
|
|
55
|
+
*/
|
|
56
|
+
const isStableBlockId = (id) => getFolioParaIdFromBlockId(id) !== null;
|
|
57
|
+
const index = (blocks) => blocks.map((block, blockIndex) => ({
|
|
58
|
+
block,
|
|
59
|
+
index: blockIndex
|
|
60
|
+
}));
|
|
61
|
+
/**
|
|
62
|
+
* Longest increasing subsequence by `revisedIndex`, assuming `pairs` is
|
|
63
|
+
* already sorted by `baseIndex` ascending. Drops any pair that would make
|
|
64
|
+
* the alignment walk backward in the revised document — the guard against
|
|
65
|
+
* both id collisions (pass 1) and any crossing match (pass 1 + 2 combined).
|
|
66
|
+
*/
|
|
67
|
+
const longestIncreasingByRevisedIndex = (pairs) => {
|
|
68
|
+
if (pairs.length === 0) return [];
|
|
69
|
+
const lengths = Array.from({ length: pairs.length }).fill(1);
|
|
70
|
+
const predecessors = Array.from({ length: pairs.length }).fill(-1);
|
|
71
|
+
let bestEnd = 0;
|
|
72
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
73
|
+
for (let j = 0; j < i; j++) {
|
|
74
|
+
const current = pairs[j];
|
|
75
|
+
const candidate = pairs[i];
|
|
76
|
+
if (!current || !candidate) continue;
|
|
77
|
+
if (current.revisedIndex < candidate.revisedIndex && (lengths[j] ?? 0) + 1 > (lengths[i] ?? 0)) {
|
|
78
|
+
lengths[i] = (lengths[j] ?? 0) + 1;
|
|
79
|
+
predecessors[i] = j;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if ((lengths[i] ?? 0) > (lengths[bestEnd] ?? 0)) bestEnd = i;
|
|
83
|
+
}
|
|
84
|
+
const ordered = [];
|
|
85
|
+
for (let cursor = bestEnd; cursor !== -1; cursor = predecessors[cursor] ?? -1) {
|
|
86
|
+
const pair = pairs[cursor];
|
|
87
|
+
if (pair) ordered.push(pair);
|
|
88
|
+
}
|
|
89
|
+
return ordered.toReversed();
|
|
90
|
+
};
|
|
91
|
+
/** Pass 1: pair blocks with equal, non-`seq-NNNN` ids. See the module doc comment. */
|
|
92
|
+
const pairByStableId = (base, revised) => {
|
|
93
|
+
const revisedIndexById = /* @__PURE__ */ new Map();
|
|
94
|
+
revised.forEach((block, revisedIndex) => {
|
|
95
|
+
if (isStableBlockId(block.id)) revisedIndexById.set(block.id, revisedIndex);
|
|
96
|
+
});
|
|
97
|
+
const candidates = [];
|
|
98
|
+
base.forEach((block, baseIndex) => {
|
|
99
|
+
if (!isStableBlockId(block.id)) return;
|
|
100
|
+
const revisedIndex = revisedIndexById.get(block.id);
|
|
101
|
+
if (revisedIndex !== void 0) candidates.push({
|
|
102
|
+
baseIndex,
|
|
103
|
+
revisedIndex
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return longestIncreasingByRevisedIndex(candidates);
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Cell budget for pass 2's O(m·n) exact-text LCS table (`dp` below allocates
|
|
110
|
+
* `(m + 1) * (n + 1)` numbers). A document with no `w14:paraId`s — or an
|
|
111
|
+
* adversarial one crafted to defeat pass 1 — can leave thousands of blocks
|
|
112
|
+
* unpaired on both sides; without a cap, `pairByExactText` would allocate a
|
|
113
|
+
* quadratic-sized table for it. Past this budget, {@link exceedsLcsBudget}
|
|
114
|
+
* makes pass 2 back off entirely so alignment falls through to pass 3's
|
|
115
|
+
* linear positional zip instead — pairing is less precise for these
|
|
116
|
+
* degenerate inputs, but memory use stays bounded.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_LCS_CELLS = 4e6;
|
|
119
|
+
/** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
|
|
120
|
+
const exceedsLcsBudget = (unpairedBaseCount, unpairedRevisedCount) => unpairedBaseCount * unpairedRevisedCount > MAX_LCS_CELLS;
|
|
121
|
+
/** Pass 2: order-preserving LCS by exact text equality over the blocks pass 1 left unpaired. */
|
|
122
|
+
const pairByExactText = (base, revised) => {
|
|
123
|
+
const m = base.length;
|
|
124
|
+
const n = revised.length;
|
|
125
|
+
if (m === 0 || n === 0) return [];
|
|
126
|
+
if (exceedsLcsBudget(m, n)) return [];
|
|
127
|
+
const stride = n + 1;
|
|
128
|
+
const dp = new Int32Array((m + 1) * stride);
|
|
129
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
130
|
+
const rowOffset = i * stride;
|
|
131
|
+
const nextRowOffset = (i + 1) * stride;
|
|
132
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
133
|
+
const baseText = base[i]?.block.text;
|
|
134
|
+
const revisedText = revised[j]?.block.text;
|
|
135
|
+
dp[rowOffset + j] = baseText === revisedText ? (dp[nextRowOffset + j + 1] ?? 0) + 1 : Math.max(dp[nextRowOffset + j] ?? 0, dp[rowOffset + j + 1] ?? 0);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const pairs = [];
|
|
139
|
+
let i = 0;
|
|
140
|
+
let j = 0;
|
|
141
|
+
while (i < m && j < n) {
|
|
142
|
+
const baseEntry = base[i];
|
|
143
|
+
const revisedEntry = revised[j];
|
|
144
|
+
if (!baseEntry || !revisedEntry) break;
|
|
145
|
+
if (baseEntry.block.text === revisedEntry.block.text) {
|
|
146
|
+
pairs.push({
|
|
147
|
+
baseIndex: baseEntry.index,
|
|
148
|
+
revisedIndex: revisedEntry.index
|
|
149
|
+
});
|
|
150
|
+
i++;
|
|
151
|
+
j++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if ((dp[(i + 1) * stride + j] ?? 0) >= (dp[i * stride + j + 1] ?? 0)) i++;
|
|
155
|
+
else j++;
|
|
156
|
+
}
|
|
157
|
+
return pairs;
|
|
158
|
+
};
|
|
159
|
+
const ELLIPSIS = "…";
|
|
160
|
+
const UNCHANGED_EDGE_CHARS = 30;
|
|
161
|
+
/** Collapse a long `equal`-type run to ~30 chars on each side with an ellipsis in between. */
|
|
162
|
+
const truncateUnchangedRun = (text) => {
|
|
163
|
+
if (text.length <= 61) return text;
|
|
164
|
+
return `${text.slice(0, UNCHANGED_EDGE_CHARS)}${ELLIPSIS}${text.slice(-30)}`;
|
|
165
|
+
};
|
|
166
|
+
/** Render one word-diff segment: plain for `equal` (truncated if long), bracketed for `del` / `ins`. */
|
|
167
|
+
const renderSegment = (segment) => {
|
|
168
|
+
switch (segment.type) {
|
|
169
|
+
case "equal": return truncateUnchangedRun(segment.text);
|
|
170
|
+
case "del": return `[-${segment.text}-]`;
|
|
171
|
+
case "ins": return `{+${segment.text}+}`;
|
|
172
|
+
default: return "";
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const formatChangeLine = (change) => {
|
|
176
|
+
if (change.type === "added") return `+ [${change.blockId}] added: ${change.text}`;
|
|
177
|
+
if (change.type === "deleted") return `- [${change.blockId}] deleted: ${change.text}`;
|
|
178
|
+
return `~ [${change.blockId}] modified: ${change.segments.map(renderSegment).join("")}`;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Compare two `.docx` buffers and return a structured, block-level diff.
|
|
182
|
+
* See the module doc comment for the as-accepted comparison semantics and
|
|
183
|
+
* the three-pass alignment algorithm.
|
|
184
|
+
*/
|
|
185
|
+
const compareDocxVersions = async (base, revised) => {
|
|
186
|
+
const [baseReviewer, revisedReviewer] = await Promise.all([FolioDocxReviewer.fromBuffer(base), FolioDocxReviewer.fromBuffer(revised)]);
|
|
187
|
+
const baseBlocks = baseReviewer.snapshot().blocks;
|
|
188
|
+
const revisedBlocks = revisedReviewer.snapshot().blocks;
|
|
189
|
+
const stableIdAnchors = pairByStableId(baseBlocks, revisedBlocks);
|
|
190
|
+
const usedBaseIndexes = new Set(stableIdAnchors.map((anchor) => anchor.baseIndex));
|
|
191
|
+
const usedRevisedIndexes = new Set(stableIdAnchors.map((anchor) => anchor.revisedIndex));
|
|
192
|
+
const exactTextAnchors = pairByExactText(index(baseBlocks).filter(({ index: i }) => !usedBaseIndexes.has(i)), index(revisedBlocks).filter(({ index: i }) => !usedRevisedIndexes.has(i)));
|
|
193
|
+
const anchors = longestIncreasingByRevisedIndex([...stableIdAnchors, ...exactTextAnchors].toSorted((a, b) => a.baseIndex - b.baseIndex));
|
|
194
|
+
const changes = [];
|
|
195
|
+
const counts = {
|
|
196
|
+
added: 0,
|
|
197
|
+
deleted: 0,
|
|
198
|
+
modified: 0,
|
|
199
|
+
unchanged: 0
|
|
200
|
+
};
|
|
201
|
+
/** Pass 3: positionally zip the leftover blocks in one gap between anchors. */
|
|
202
|
+
const emitGap = (baseFrom, baseTo, revisedFrom, revisedTo) => {
|
|
203
|
+
const baseSlice = baseBlocks.slice(baseFrom, baseTo);
|
|
204
|
+
const revisedSlice = revisedBlocks.slice(revisedFrom, revisedTo);
|
|
205
|
+
const pairedCount = Math.min(baseSlice.length, revisedSlice.length);
|
|
206
|
+
for (let k = 0; k < pairedCount; k++) {
|
|
207
|
+
const baseBlock = baseSlice[k];
|
|
208
|
+
const revisedBlock = revisedSlice[k];
|
|
209
|
+
if (!baseBlock || !revisedBlock) continue;
|
|
210
|
+
if (baseBlock.text === revisedBlock.text) {
|
|
211
|
+
counts.unchanged++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
counts.modified++;
|
|
215
|
+
changes.push({
|
|
216
|
+
type: "modified",
|
|
217
|
+
blockId: revisedBlock.id,
|
|
218
|
+
kind: revisedBlock.kind,
|
|
219
|
+
segments: diffWordSegments(baseBlock.text, revisedBlock.text)
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
for (let k = pairedCount; k < baseSlice.length; k++) {
|
|
223
|
+
const baseBlock = baseSlice[k];
|
|
224
|
+
if (!baseBlock) continue;
|
|
225
|
+
counts.deleted++;
|
|
226
|
+
changes.push({
|
|
227
|
+
type: "deleted",
|
|
228
|
+
blockId: baseBlock.id,
|
|
229
|
+
kind: baseBlock.kind,
|
|
230
|
+
text: baseBlock.text
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
for (let k = pairedCount; k < revisedSlice.length; k++) {
|
|
234
|
+
const revisedBlock = revisedSlice[k];
|
|
235
|
+
if (!revisedBlock) continue;
|
|
236
|
+
counts.added++;
|
|
237
|
+
changes.push({
|
|
238
|
+
type: "added",
|
|
239
|
+
blockId: revisedBlock.id,
|
|
240
|
+
kind: revisedBlock.kind,
|
|
241
|
+
text: revisedBlock.text
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
let baseCursor = 0;
|
|
246
|
+
let revisedCursor = 0;
|
|
247
|
+
for (const anchor of anchors) {
|
|
248
|
+
emitGap(baseCursor, anchor.baseIndex, revisedCursor, anchor.revisedIndex);
|
|
249
|
+
const baseBlock = baseBlocks[anchor.baseIndex];
|
|
250
|
+
const revisedBlock = revisedBlocks[anchor.revisedIndex];
|
|
251
|
+
if (baseBlock && revisedBlock) if (baseBlock.text === revisedBlock.text) counts.unchanged++;
|
|
252
|
+
else {
|
|
253
|
+
counts.modified++;
|
|
254
|
+
changes.push({
|
|
255
|
+
type: "modified",
|
|
256
|
+
blockId: revisedBlock.id,
|
|
257
|
+
kind: revisedBlock.kind,
|
|
258
|
+
segments: diffWordSegments(baseBlock.text, revisedBlock.text)
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
baseCursor = anchor.baseIndex + 1;
|
|
262
|
+
revisedCursor = anchor.revisedIndex + 1;
|
|
263
|
+
}
|
|
264
|
+
emitGap(baseCursor, baseBlocks.length, revisedCursor, revisedBlocks.length);
|
|
265
|
+
return {
|
|
266
|
+
changes,
|
|
267
|
+
summaryCounts: counts
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Render a {@link FolioAgentVersionDiff} as compact, deterministic text for a
|
|
272
|
+
* model prompt: a header line with the summary counts, then one line per
|
|
273
|
+
* change — `~ [blockId] modified: …`, `+ [blockId] added: …`,
|
|
274
|
+
* `- [blockId] deleted: …`. A modified block's segments render inline using
|
|
275
|
+
* `git diff --word-diff` porcelain conventions (`[-removed-]`, `{+added+}`,
|
|
276
|
+
* plain text for unchanged runs, truncated to ~30 characters on each side
|
|
277
|
+
* when long). The format is stable across calls — do not change it without
|
|
278
|
+
* checking `compare.test.ts`.
|
|
279
|
+
*/
|
|
280
|
+
const formatVersionDiffForLLM = (diff) => {
|
|
281
|
+
const { added, deleted, modified, unchanged } = diff.summaryCounts;
|
|
282
|
+
return [`Version diff: ${added} added, ${deleted} deleted, ${modified} modified, ${unchanged} unchanged`, ...diff.changes.map(formatChangeLine)].join("\n");
|
|
283
|
+
};
|
|
284
|
+
//#endregion
|
|
285
|
+
export { compareDocxVersions, exceedsLcsBudget, formatVersionDiffForLLM };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FolioToolCallResult } from "./types.js";
|
|
2
|
+
import { FolioAgentBridge } from "./bridge.js";
|
|
3
|
+
|
|
4
|
+
//#region src/execute.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Execute one tool call against a {@link FolioAgentBridge}. Every EXPECTED
|
|
7
|
+
* failure — bad arguments, an unknown tool name, a capability the bridge does
|
|
8
|
+
* not implement, a domain-level skip — comes back as `{ ok: false, error }`
|
|
9
|
+
* with a message meant to be fed straight back to the model; it is never
|
|
10
|
+
* thrown. This function is the sole boundary layer allowed to catch an
|
|
11
|
+
* unexpected throw from the bridge and fold it into that same shape (per
|
|
12
|
+
* AGENTS.md, try/catch is acceptable at boundary layers).
|
|
13
|
+
*
|
|
14
|
+
* Every bridge member used here ({@link FolioDocxReviewer} and the live-editor
|
|
15
|
+
* ref) is synchronous, so this stays synchronous too.
|
|
16
|
+
*/
|
|
17
|
+
declare const executeFolioToolCall: (name: string, args: unknown, bridge: FolioAgentBridge) => FolioToolCallResult;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { executeFolioToolCall };
|
package/dist/execute.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { explainTextTooLong, parseAddCommentInput, parseSuggestChangesInput } from "./parse.js";
|
|
2
|
+
import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";
|
|
3
|
+
//#region src/execute.ts
|
|
4
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
|
|
6
|
+
const ok = (result) => ({
|
|
7
|
+
ok: true,
|
|
8
|
+
result
|
|
9
|
+
});
|
|
10
|
+
const fail = (error) => ({
|
|
11
|
+
ok: false,
|
|
12
|
+
error
|
|
13
|
+
});
|
|
14
|
+
const VALID_TOOL_NAMES = Object.values(FOLIO_AGENT_TOOL_NAMES);
|
|
15
|
+
/** `find_text` requires a short `query` and caps how much of a large match set it returns in one call. */
|
|
16
|
+
const MAX_QUERY_LENGTH = 1e3;
|
|
17
|
+
const MAX_FIND_MATCHES = 200;
|
|
18
|
+
/**
|
|
19
|
+
* Execute one tool call against a {@link FolioAgentBridge}. Every EXPECTED
|
|
20
|
+
* failure — bad arguments, an unknown tool name, a capability the bridge does
|
|
21
|
+
* not implement, a domain-level skip — comes back as `{ ok: false, error }`
|
|
22
|
+
* with a message meant to be fed straight back to the model; it is never
|
|
23
|
+
* thrown. This function is the sole boundary layer allowed to catch an
|
|
24
|
+
* unexpected throw from the bridge and fold it into that same shape (per
|
|
25
|
+
* AGENTS.md, try/catch is acceptable at boundary layers).
|
|
26
|
+
*
|
|
27
|
+
* Every bridge member used here ({@link FolioDocxReviewer} and the live-editor
|
|
28
|
+
* ref) is synchronous, so this stays synchronous too.
|
|
29
|
+
*/
|
|
30
|
+
const executeFolioToolCall = (name, args, bridge) => {
|
|
31
|
+
if (!VALID_TOOL_NAMES.includes(name)) return fail(`Unknown tool "${name}". Valid tools: ${VALID_TOOL_NAMES.join(", ")}.`);
|
|
32
|
+
try {
|
|
33
|
+
return dispatch(name, args, bridge);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const dispatch = (name, args, bridge) => {
|
|
39
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.readDocument) return readDocument(bridge);
|
|
40
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.findText) return findText(args, bridge);
|
|
41
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.readComments) return readComments(args, bridge);
|
|
42
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.readChanges) return ok(bridge.getChanges());
|
|
43
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.addComment) return addComment(args, bridge);
|
|
44
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.suggestChanges) return suggestChanges(args, bridge);
|
|
45
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.replyComment) return replyComment(args, bridge);
|
|
46
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.resolveComment) return resolveComment(args, bridge);
|
|
47
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.readPage) return readPage(args, bridge);
|
|
48
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.readSelection) return readSelection(bridge);
|
|
49
|
+
if (name === FOLIO_AGENT_TOOL_NAMES.scrollToBlock) return scrollToBlock(args, bridge);
|
|
50
|
+
return fail(`Unknown tool "${name}".`);
|
|
51
|
+
};
|
|
52
|
+
const readDocument = (bridge) => {
|
|
53
|
+
const { blocks } = bridge.snapshot();
|
|
54
|
+
return ok(blocks.map((block) => ({
|
|
55
|
+
blockId: block.id,
|
|
56
|
+
kind: block.kind,
|
|
57
|
+
text: block.text
|
|
58
|
+
})));
|
|
59
|
+
};
|
|
60
|
+
const CONTEXT_RADIUS = 40;
|
|
61
|
+
const findTextMatches = (blocks, query, matchCase) => {
|
|
62
|
+
const matches = [];
|
|
63
|
+
let totalMatches = 0;
|
|
64
|
+
const needle = matchCase ? query : query.toLowerCase();
|
|
65
|
+
for (const block of blocks) {
|
|
66
|
+
const haystack = matchCase ? block.text : block.text.toLowerCase();
|
|
67
|
+
let occurrence = 0;
|
|
68
|
+
let fromIndex = 0;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const at = haystack.indexOf(needle, fromIndex);
|
|
71
|
+
if (at === -1) break;
|
|
72
|
+
totalMatches += 1;
|
|
73
|
+
if (matches.length < MAX_FIND_MATCHES) {
|
|
74
|
+
const contextStart = Math.max(0, at - CONTEXT_RADIUS);
|
|
75
|
+
const contextEnd = Math.min(block.text.length, at + query.length + CONTEXT_RADIUS);
|
|
76
|
+
matches.push({
|
|
77
|
+
blockId: block.id,
|
|
78
|
+
occurrenceInBlock: occurrence,
|
|
79
|
+
context: block.text.slice(contextStart, contextEnd)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
occurrence += 1;
|
|
83
|
+
fromIndex = at + needle.length;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
matches,
|
|
88
|
+
totalMatches
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const findText = (args, bridge) => {
|
|
92
|
+
if (!isPlainObject(args)) return fail("find_text expects an object with a non-empty `query` string.");
|
|
93
|
+
const query = args["query"];
|
|
94
|
+
const matchCase = args["matchCase"];
|
|
95
|
+
if (!isNonEmptyString(query)) return fail("find_text requires a non-empty string `query`.");
|
|
96
|
+
if (query.length > MAX_QUERY_LENGTH) return fail(`find_text's \`query\` is ${query.length.toLocaleString()} characters, over the ${MAX_QUERY_LENGTH.toLocaleString()}-character limit; shorten it.`);
|
|
97
|
+
if (matchCase !== void 0 && typeof matchCase !== "boolean") return fail("find_text's `matchCase` must be a boolean when provided.");
|
|
98
|
+
const { blocks } = bridge.snapshot();
|
|
99
|
+
const { matches, totalMatches } = findTextMatches(blocks, query, matchCase === true);
|
|
100
|
+
return ok({
|
|
101
|
+
matches,
|
|
102
|
+
truncated: totalMatches > matches.length,
|
|
103
|
+
totalMatches
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
const isCommentFilter = (value) => value === "all" || value === "open" || value === "resolved";
|
|
107
|
+
const readComments = (args, bridge) => {
|
|
108
|
+
const filter = isPlainObject(args) ? args["filter"] : void 0;
|
|
109
|
+
if (filter !== void 0 && !isCommentFilter(filter)) return fail("read_comments' `filter` must be one of \"all\", \"open\", \"resolved\" when provided.");
|
|
110
|
+
const comments = bridge.getComments();
|
|
111
|
+
if (filter === void 0 || filter === "all") return ok(comments);
|
|
112
|
+
const wantResolved = filter === "resolved";
|
|
113
|
+
return ok(comments.filter((comment) => comment.resolved === wantResolved));
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Turn a `FolioAIEditSkipReason` into a plain-language reason a model can act
|
|
117
|
+
* on — what to change and retry, not just a machine code.
|
|
118
|
+
*/
|
|
119
|
+
const explainSkipReason = (reason) => {
|
|
120
|
+
if (reason === "missingBlock") return "blockId not found; re-read the document (read_document or find_text) and retry with a fresh block id.";
|
|
121
|
+
if (reason === "changedBlock") return "the block changed since your snapshot; re-read the document and retry with fresh ids.";
|
|
122
|
+
if (reason === "ambiguousFind") return "`find` matches more than once in this block; narrow it so it matches exactly once, or use a replaceBlock operation instead.";
|
|
123
|
+
if (reason === "missingFind") return "`find` was not found in this block; re-read the block's current text and retry.";
|
|
124
|
+
if (reason === "unsupportedBlock") return "this block kind does not support this operation.";
|
|
125
|
+
if (reason === "emptyOperation") return "this operation has no effect; nothing to apply.";
|
|
126
|
+
if (reason === "noopOperation") return "this operation would not change the document (the text already matches what you asked for).";
|
|
127
|
+
return reason;
|
|
128
|
+
};
|
|
129
|
+
const summarizeApplyResult = (result) => ({
|
|
130
|
+
applied: result.applied.map((entry) => ({ id: entry.id })),
|
|
131
|
+
skipped: result.skipped.map((entry) => ({
|
|
132
|
+
id: entry.id,
|
|
133
|
+
reason: explainSkipReason(entry.reason)
|
|
134
|
+
}))
|
|
135
|
+
});
|
|
136
|
+
const addComment = (args, bridge) => {
|
|
137
|
+
const parsed = parseAddCommentInput(args);
|
|
138
|
+
if (!parsed.ok) return fail(parsed.error);
|
|
139
|
+
return ok(summarizeApplyResult(bridge.applyOperations([parsed.operation])));
|
|
140
|
+
};
|
|
141
|
+
const suggestChanges = (args, bridge) => {
|
|
142
|
+
const parsed = parseSuggestChangesInput(args);
|
|
143
|
+
if (!parsed.ok) return fail(parsed.error);
|
|
144
|
+
return ok(summarizeApplyResult(bridge.applyOperations(parsed.operations)));
|
|
145
|
+
};
|
|
146
|
+
const replyComment = (args, bridge) => {
|
|
147
|
+
if (!isPlainObject(args)) return fail("reply_comment expects an object with `commentId` and `text` strings.");
|
|
148
|
+
const commentId = args["commentId"];
|
|
149
|
+
const text = args["text"];
|
|
150
|
+
if (!isNonEmptyString(commentId)) return fail("reply_comment requires a non-empty string `commentId`.");
|
|
151
|
+
if (!isNonEmptyString(text)) return fail("reply_comment requires a non-empty string `text`.");
|
|
152
|
+
if (text.length > 1e5) return fail(explainTextTooLong("reply_comment's `text`", text.length));
|
|
153
|
+
if (!bridge.replyToComment(commentId, text)) return fail(`No comment with id "${commentId}" was found.`);
|
|
154
|
+
return ok({ replied: true });
|
|
155
|
+
};
|
|
156
|
+
const resolveComment = (args, bridge) => {
|
|
157
|
+
if (!isPlainObject(args)) return fail("resolve_comment expects an object with a `commentId` string.");
|
|
158
|
+
const commentId = args["commentId"];
|
|
159
|
+
const reopen = args["reopen"];
|
|
160
|
+
if (!isNonEmptyString(commentId)) return fail("resolve_comment requires a non-empty string `commentId`.");
|
|
161
|
+
if (reopen !== void 0 && typeof reopen !== "boolean") return fail("resolve_comment's `reopen` must be a boolean when provided.");
|
|
162
|
+
const resolved = reopen !== true;
|
|
163
|
+
if (!bridge.resolveComment(commentId, resolved)) return fail(`No comment with id "${commentId}" was found.`);
|
|
164
|
+
return ok({ resolved });
|
|
165
|
+
};
|
|
166
|
+
const readPage = (args, bridge) => {
|
|
167
|
+
if (!bridge.getPageText) return fail("This editor surface does not support read_page (no live paginated view).");
|
|
168
|
+
const page = isPlainObject(args) ? args["page"] : void 0;
|
|
169
|
+
if (typeof page !== "number" || !Number.isInteger(page) || page < 1) return fail("read_page requires an integer `page` >= 1.");
|
|
170
|
+
const pageCount = bridge.getPageCount?.();
|
|
171
|
+
if (pageCount !== void 0 && page > pageCount) return fail(`read_page's \`page\` (${page}) exceeds the document's page count (${pageCount}).`);
|
|
172
|
+
return ok({
|
|
173
|
+
page,
|
|
174
|
+
text: bridge.getPageText(page)
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
const readSelection = (bridge) => {
|
|
178
|
+
if (!bridge.getSelectionText) return fail("This editor surface does not support read_selection (no live selection).");
|
|
179
|
+
return ok({ text: bridge.getSelectionText() });
|
|
180
|
+
};
|
|
181
|
+
const scrollToBlock = (args, bridge) => {
|
|
182
|
+
if (!bridge.scrollToBlock) return fail("This editor surface does not support scroll_to_block (no live editor view).");
|
|
183
|
+
const blockId = isPlainObject(args) ? args["blockId"] : void 0;
|
|
184
|
+
if (!isNonEmptyString(blockId)) return fail("scroll_to_block requires a non-empty string `blockId`.");
|
|
185
|
+
return ok({ scrolled: bridge.scrollToBlock(blockId) });
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
188
|
+
export { executeFolioToolCall };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { FOLIO_AGENT_TOOL_NAMES, FolioAgentApplyOperationsSummary, FolioAgentBlock, FolioAgentChange, FolioAgentComment, FolioAgentCommentFilter, FolioAgentCommentReply, FolioAgentTextMatch, FolioAgentToolDefinition, FolioAgentToolName, FolioToolCallResult } from "./types.js";
|
|
2
|
+
import { FolioAgentBridge } from "./bridge.js";
|
|
3
|
+
import { CreateEditorRefBridgeOptions, FolioAgentEditorRefLike, createEditorRefBridge } from "./bridges/editor-ref.js";
|
|
4
|
+
import { CreateReviewerBridgeOptions, createReviewerBridge } from "./bridges/reviewer.js";
|
|
5
|
+
import { FolioAgentBlockDiff, FolioAgentVersionDiff, FolioAgentVersionDiffSegment, compareDocxVersions, formatVersionDiffForLLM } from "./compare.js";
|
|
6
|
+
import { executeFolioToolCall } from "./execute.js";
|
|
7
|
+
import { ParseAddCommentResult, ParseSuggestChangesResult, parseAddCommentInput, parseSuggestChangesInput } from "./parse.js";
|
|
8
|
+
import { AnthropicToolDefinition, OpenAIToolDefinition, toAnthropicTools, toOpenAITools } from "./providers.js";
|
|
9
|
+
import { FOLIO_AGENT_TOOLS, getFolioToolDefinitions } from "./tools.js";
|
|
10
|
+
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { compareDocxVersions, formatVersionDiffForLLM } from "./compare.js";
|
|
2
|
+
import { parseAddCommentInput, parseSuggestChangesInput } from "./parse.js";
|
|
3
|
+
import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";
|
|
4
|
+
import { executeFolioToolCall } from "./execute.js";
|
|
5
|
+
import { createEditorRefBridge } from "./bridges/editor-ref.js";
|
|
6
|
+
import { createReviewerBridge } from "./bridges/reviewer.js";
|
|
7
|
+
import { toAnthropicTools, toOpenAITools } from "./providers.js";
|
|
8
|
+
import { FOLIO_AGENT_TOOLS, getFolioToolDefinitions } from "./tools.js";
|
|
9
|
+
export { FOLIO_AGENT_TOOLS, FOLIO_AGENT_TOOL_NAMES, compareDocxVersions, createEditorRefBridge, createReviewerBridge, executeFolioToolCall, formatVersionDiffForLLM, getFolioToolDefinitions, parseAddCommentInput, parseSuggestChangesInput, toAnthropicTools, toOpenAITools };
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { FolioAIEditOperation } from "@stll/folio-core/server";
|
|
2
|
+
|
|
3
|
+
//#region src/parse.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Hard caps on `suggest_changes` / `add_comment` / `reply_comment` input
|
|
6
|
+
* size. Without these, a single tool call could ask the bridge to apply an
|
|
7
|
+
* unbounded number of operations, or push an arbitrarily large string into
|
|
8
|
+
* the tracked-changes engine, in one shot. `execute.ts` reuses
|
|
9
|
+
* {@link MAX_OPERATION_TEXT_LENGTH} for `reply_comment`'s text cap too, so
|
|
10
|
+
* the limit stays a single number shared across every text-bearing tool.
|
|
11
|
+
*/
|
|
12
|
+
declare const MAX_OPERATIONS_PER_CALL = 50;
|
|
13
|
+
declare const MAX_OPERATION_TEXT_LENGTH = 100000;
|
|
14
|
+
/** Plain-language error for a string argument over {@link MAX_OPERATION_TEXT_LENGTH}. */
|
|
15
|
+
declare const explainTextTooLong: (label: string, length: number) => string;
|
|
16
|
+
/** Result of {@link parseAddCommentInput}. */
|
|
17
|
+
type ParseAddCommentResult = {
|
|
18
|
+
ok: true;
|
|
19
|
+
operation: FolioAIEditOperation;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Validate `add_comment`'s raw tool-call arguments and build the
|
|
26
|
+
* `commentOnBlock` {@link FolioAIEditOperation} it applies. Pure: does not
|
|
27
|
+
* touch a bridge or document.
|
|
28
|
+
*/
|
|
29
|
+
declare const parseAddCommentInput: (args: unknown) => ParseAddCommentResult;
|
|
30
|
+
/** Result of {@link parseSuggestChangesInput}. */
|
|
31
|
+
type ParseSuggestChangesResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
operations: FolioAIEditOperation[];
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Validate `suggest_changes`' raw tool-call arguments and build the
|
|
40
|
+
* {@link FolioAIEditOperation}s it applies. Pure: does not touch a bridge or
|
|
41
|
+
* document.
|
|
42
|
+
*/
|
|
43
|
+
declare const parseSuggestChangesInput: (args: unknown) => ParseSuggestChangesResult;
|
|
44
|
+
//#endregion
|
|
45
|
+
export { MAX_OPERATIONS_PER_CALL, MAX_OPERATION_TEXT_LENGTH, ParseAddCommentResult, ParseSuggestChangesResult, explainTextTooLong, parseAddCommentInput, parseSuggestChangesInput };
|