@khanglvm/outline-cli 0.1.1
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/.env.test.example +2 -0
- package/AGENTS.md +107 -0
- package/CHANGELOG.md +102 -0
- package/README.md +244 -0
- package/bin/outline-agent.js +5 -0
- package/bin/outline-cli.js +13 -0
- package/package.json +25 -0
- package/scripts/generate-entry-integrity.mjs +123 -0
- package/scripts/release.mjs +353 -0
- package/src/action-gate.js +257 -0
- package/src/agent-skills.js +759 -0
- package/src/cli.js +956 -0
- package/src/config-store.js +720 -0
- package/src/entry-integrity-binding.generated.js +6 -0
- package/src/entry-integrity-manifest.generated.js +74 -0
- package/src/entry-integrity.js +112 -0
- package/src/errors.js +15 -0
- package/src/outline-client.js +237 -0
- package/src/result-store.js +183 -0
- package/src/secure-keyring.js +290 -0
- package/src/tool-arg-schemas.js +2346 -0
- package/src/tools.extended.js +3252 -0
- package/src/tools.js +1056 -0
- package/src/tools.mutation.js +1807 -0
- package/src/tools.navigation.js +2273 -0
- package/src/tools.platform.js +554 -0
- package/src/utils.js +176 -0
- package/test/action-gate.unit.test.js +157 -0
- package/test/agent-skills.unit.test.js +52 -0
- package/test/config-store.unit.test.js +89 -0
- package/test/hardening.unit.test.js +3778 -0
- package/test/live.integration.test.js +5140 -0
- package/test/profile-selection.unit.test.js +279 -0
- package/test/security.unit.test.js +113 -0
|
@@ -0,0 +1,1807 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { ApiError, CliError } from "./errors.js";
|
|
3
|
+
import {
|
|
4
|
+
assertPerformAction,
|
|
5
|
+
consumeDocumentDeleteReadReceipt,
|
|
6
|
+
getDocumentDeleteReadReceipt,
|
|
7
|
+
} from "./action-gate.js";
|
|
8
|
+
import { compactValue, mapLimit, toInteger } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
function normalizeDocumentSummary(doc, view = "summary", excerptChars = 220) {
|
|
11
|
+
if (!doc) {
|
|
12
|
+
return doc;
|
|
13
|
+
}
|
|
14
|
+
if (view === "full") {
|
|
15
|
+
return doc;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const summary = {
|
|
19
|
+
id: doc.id,
|
|
20
|
+
title: doc.title,
|
|
21
|
+
collectionId: doc.collectionId,
|
|
22
|
+
parentDocumentId: doc.parentDocumentId,
|
|
23
|
+
revision: doc.revision,
|
|
24
|
+
updatedAt: doc.updatedAt,
|
|
25
|
+
publishedAt: doc.publishedAt,
|
|
26
|
+
urlId: doc.urlId,
|
|
27
|
+
emoji: doc.emoji,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (doc.text) {
|
|
31
|
+
summary.excerpt = doc.text.length > excerptChars ? `${doc.text.slice(0, excerptChars)}...` : doc.text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return summary;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildRevisionConflict({ id, expectedRevision, actualRevision }) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
code: "revision_conflict",
|
|
41
|
+
message: "Document revision changed since last read",
|
|
42
|
+
id,
|
|
43
|
+
expectedRevision,
|
|
44
|
+
actualRevision,
|
|
45
|
+
updated: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureUpdatePayload(args) {
|
|
50
|
+
const body = compactValue({
|
|
51
|
+
id: args.id,
|
|
52
|
+
title: args.title,
|
|
53
|
+
text: args.text,
|
|
54
|
+
icon: args.icon,
|
|
55
|
+
color: args.color,
|
|
56
|
+
fullWidth: args.fullWidth,
|
|
57
|
+
templateId: args.templateId,
|
|
58
|
+
collectionId: args.collectionId,
|
|
59
|
+
insightsEnabled: args.insightsEnabled,
|
|
60
|
+
editMode: args.editMode,
|
|
61
|
+
publish: args.publish,
|
|
62
|
+
dataAttributes: args.dataAttributes,
|
|
63
|
+
}) || {};
|
|
64
|
+
|
|
65
|
+
if (!body.id) {
|
|
66
|
+
throw new CliError("id is required");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return body;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function splitLines(text) {
|
|
73
|
+
return String(text ?? "").replace(/\r\n/g, "\n").split("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildLcsMatrix(a, b) {
|
|
77
|
+
const m = a.length;
|
|
78
|
+
const n = b.length;
|
|
79
|
+
const matrix = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
80
|
+
|
|
81
|
+
for (let i = m - 1; i >= 0; i -= 1) {
|
|
82
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
83
|
+
if (a[i] === b[j]) {
|
|
84
|
+
matrix[i][j] = matrix[i + 1][j + 1] + 1;
|
|
85
|
+
} else {
|
|
86
|
+
matrix[i][j] = Math.max(matrix[i + 1][j], matrix[i][j + 1]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return matrix;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function computeLineDiff(currentText, proposedText) {
|
|
95
|
+
const a = splitLines(currentText);
|
|
96
|
+
const b = splitLines(proposedText);
|
|
97
|
+
const matrix = buildLcsMatrix(a, b);
|
|
98
|
+
|
|
99
|
+
const ops = [];
|
|
100
|
+
let i = 0;
|
|
101
|
+
let j = 0;
|
|
102
|
+
while (i < a.length && j < b.length) {
|
|
103
|
+
if (a[i] === b[j]) {
|
|
104
|
+
ops.push({ type: "equal", line: a[i] });
|
|
105
|
+
i += 1;
|
|
106
|
+
j += 1;
|
|
107
|
+
} else if (matrix[i + 1][j] >= matrix[i][j + 1]) {
|
|
108
|
+
ops.push({ type: "remove", line: a[i] });
|
|
109
|
+
i += 1;
|
|
110
|
+
} else {
|
|
111
|
+
ops.push({ type: "add", line: b[j] });
|
|
112
|
+
j += 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
while (i < a.length) {
|
|
117
|
+
ops.push({ type: "remove", line: a[i] });
|
|
118
|
+
i += 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
while (j < b.length) {
|
|
122
|
+
ops.push({ type: "add", line: b[j] });
|
|
123
|
+
j += 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hunks = [];
|
|
127
|
+
let pointerOld = 1;
|
|
128
|
+
let pointerNew = 1;
|
|
129
|
+
let added = 0;
|
|
130
|
+
let removed = 0;
|
|
131
|
+
let unchanged = 0;
|
|
132
|
+
|
|
133
|
+
let pending = null;
|
|
134
|
+
|
|
135
|
+
function flushPending() {
|
|
136
|
+
if (!pending) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const lines = pending.lines;
|
|
140
|
+
const hasAdds = lines.some((l) => l.type === "add");
|
|
141
|
+
const hasRemoves = lines.some((l) => l.type === "remove");
|
|
142
|
+
pending.kind = hasAdds && hasRemoves ? "change" : hasAdds ? "add" : "remove";
|
|
143
|
+
hunks.push(pending);
|
|
144
|
+
pending = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const op of ops) {
|
|
148
|
+
if (op.type === "equal") {
|
|
149
|
+
unchanged += 1;
|
|
150
|
+
flushPending();
|
|
151
|
+
pointerOld += 1;
|
|
152
|
+
pointerNew += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!pending) {
|
|
157
|
+
pending = {
|
|
158
|
+
oldStart: pointerOld,
|
|
159
|
+
newStart: pointerNew,
|
|
160
|
+
lines: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pending.lines.push(op);
|
|
165
|
+
|
|
166
|
+
if (op.type === "add") {
|
|
167
|
+
added += 1;
|
|
168
|
+
pointerNew += 1;
|
|
169
|
+
} else {
|
|
170
|
+
removed += 1;
|
|
171
|
+
pointerOld += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
flushPending();
|
|
176
|
+
|
|
177
|
+
const changed = hunks.filter((h) => h.kind === "change").length;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
stats: {
|
|
181
|
+
added,
|
|
182
|
+
removed,
|
|
183
|
+
changed,
|
|
184
|
+
unchanged,
|
|
185
|
+
totalCurrentLines: a.length,
|
|
186
|
+
totalProposedLines: b.length,
|
|
187
|
+
},
|
|
188
|
+
hunks,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function previewHunks(hunks, limit = 8, perHunkLineLimit = 12) {
|
|
193
|
+
return hunks.slice(0, limit).map((h) => ({
|
|
194
|
+
kind: h.kind,
|
|
195
|
+
oldStart: h.oldStart,
|
|
196
|
+
newStart: h.newStart,
|
|
197
|
+
lines: h.lines.slice(0, perHunkLineLimit).map((line) => ({
|
|
198
|
+
type: line.type,
|
|
199
|
+
line: line.line,
|
|
200
|
+
})),
|
|
201
|
+
truncated: h.lines.length > perHunkLineLimit,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildDiffPayload(diff, args = {}) {
|
|
206
|
+
const includeFullHunks = !!args.includeFullHunks;
|
|
207
|
+
return {
|
|
208
|
+
stats: diff.stats,
|
|
209
|
+
hunks: includeFullHunks
|
|
210
|
+
? diff.hunks
|
|
211
|
+
: previewHunks(diff.hunks, toInteger(args.hunkLimit, 8), toInteger(args.hunkLineLimit, 12)),
|
|
212
|
+
truncated: !includeFullHunks,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeRevisionDiffMeta(revision, view = "summary") {
|
|
217
|
+
if (!revision || typeof revision !== "object") {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (view === "full") {
|
|
221
|
+
return revision;
|
|
222
|
+
}
|
|
223
|
+
return compactValue({
|
|
224
|
+
id: revision.id,
|
|
225
|
+
documentId: revision.documentId,
|
|
226
|
+
title: revision.title,
|
|
227
|
+
createdAt: revision.createdAt,
|
|
228
|
+
createdBy: revision.createdBy
|
|
229
|
+
? {
|
|
230
|
+
id: revision.createdBy.id,
|
|
231
|
+
name: revision.createdBy.name,
|
|
232
|
+
}
|
|
233
|
+
: undefined,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resolveRevisionDocumentId(revision) {
|
|
238
|
+
if (typeof revision?.documentId === "string" && revision.documentId) {
|
|
239
|
+
return revision.documentId;
|
|
240
|
+
}
|
|
241
|
+
if (typeof revision?.document?.id === "string" && revision.document.id) {
|
|
242
|
+
return revision.document.id;
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveRevisionText(revision) {
|
|
248
|
+
if (typeof revision?.text === "string") {
|
|
249
|
+
return revision.text;
|
|
250
|
+
}
|
|
251
|
+
if (typeof revision?.document?.text === "string") {
|
|
252
|
+
return revision.document.text;
|
|
253
|
+
}
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseUnifiedPatch(patchText) {
|
|
258
|
+
const lines = splitLines(patchText);
|
|
259
|
+
const hunks = [];
|
|
260
|
+
let current = null;
|
|
261
|
+
|
|
262
|
+
const headerRe = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
|
|
263
|
+
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
if (line.startsWith("--- ") || line.startsWith("+++ ")) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Allow leading/trailing blank lines outside hunks (common from shell heredocs),
|
|
270
|
+
// but inside a hunk every line must carry a unified-diff prefix.
|
|
271
|
+
if (line === "" && !current) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const m = line.match(headerRe);
|
|
276
|
+
if (m) {
|
|
277
|
+
current = {
|
|
278
|
+
oldStart: Number(m[1]),
|
|
279
|
+
oldCount: Number(m[2] || 1),
|
|
280
|
+
newStart: Number(m[3]),
|
|
281
|
+
newCount: Number(m[4] || 1),
|
|
282
|
+
lines: [],
|
|
283
|
+
};
|
|
284
|
+
hunks.push(current);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!current) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: {
|
|
292
|
+
code: "patch_parse_failed",
|
|
293
|
+
message: "Patch contains lines outside any hunk",
|
|
294
|
+
line,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const prefix = line[0];
|
|
300
|
+
if (![" ", "+", "-"].includes(prefix)) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: {
|
|
304
|
+
code: "patch_parse_failed",
|
|
305
|
+
message: "Patch line must start with space, +, or -",
|
|
306
|
+
line,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
current.lines.push({
|
|
312
|
+
kind: prefix,
|
|
313
|
+
text: line.slice(1),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (hunks.length === 0) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
error: {
|
|
321
|
+
code: "patch_parse_failed",
|
|
322
|
+
message: "No hunks found in unified patch",
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { ok: true, hunks };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function applyUnifiedPatch(currentText, patchText) {
|
|
331
|
+
const parsed = parseUnifiedPatch(patchText);
|
|
332
|
+
if (!parsed.ok) {
|
|
333
|
+
return parsed;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const source = splitLines(currentText);
|
|
337
|
+
const output = [];
|
|
338
|
+
let index = 0;
|
|
339
|
+
|
|
340
|
+
for (const hunk of parsed.hunks) {
|
|
341
|
+
const expectedStart = Math.max(0, hunk.oldStart - 1);
|
|
342
|
+
if (expectedStart < index) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
error: {
|
|
346
|
+
code: "patch_apply_failed",
|
|
347
|
+
message: "Overlapping hunks are not supported",
|
|
348
|
+
detail: { expectedStart, index },
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
while (index < expectedStart && index < source.length) {
|
|
354
|
+
output.push(source[index]);
|
|
355
|
+
index += 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const line of hunk.lines) {
|
|
359
|
+
if (line.kind === " ") {
|
|
360
|
+
if (source[index] !== line.text) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
error: {
|
|
364
|
+
code: "patch_apply_failed",
|
|
365
|
+
message: "Context line mismatch",
|
|
366
|
+
detail: {
|
|
367
|
+
expected: line.text,
|
|
368
|
+
actual: source[index],
|
|
369
|
+
line: index + 1,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
output.push(source[index]);
|
|
375
|
+
index += 1;
|
|
376
|
+
} else if (line.kind === "-") {
|
|
377
|
+
if (source[index] !== line.text) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
error: {
|
|
381
|
+
code: "patch_apply_failed",
|
|
382
|
+
message: "Remove line mismatch",
|
|
383
|
+
detail: {
|
|
384
|
+
expected: line.text,
|
|
385
|
+
actual: source[index],
|
|
386
|
+
line: index + 1,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
index += 1;
|
|
392
|
+
} else if (line.kind === "+") {
|
|
393
|
+
output.push(line.text);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
while (index < source.length) {
|
|
399
|
+
output.push(source[index]);
|
|
400
|
+
index += 1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
text: output.join("\n"),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function escapeRegex(text) {
|
|
410
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function stableObject(value) {
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
return value.map((item) => stableObject(item));
|
|
416
|
+
}
|
|
417
|
+
if (value && typeof value === "object") {
|
|
418
|
+
const out = {};
|
|
419
|
+
for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b))) {
|
|
420
|
+
out[key] = stableObject(value[key]);
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function hashPlanObject(plan) {
|
|
428
|
+
const stable = stableObject(plan);
|
|
429
|
+
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizePlanRules(args) {
|
|
433
|
+
const raw = Array.isArray(args.rules)
|
|
434
|
+
? args.rules
|
|
435
|
+
: typeof args.find === "string"
|
|
436
|
+
? [
|
|
437
|
+
{
|
|
438
|
+
field: args.field || "both",
|
|
439
|
+
find: args.find,
|
|
440
|
+
replace: args.replace ?? "",
|
|
441
|
+
caseSensitive: args.caseSensitive,
|
|
442
|
+
wholeWord: args.wholeWord,
|
|
443
|
+
all: args.all,
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
: [];
|
|
447
|
+
|
|
448
|
+
if (raw.length === 0) {
|
|
449
|
+
throw new CliError("documents.plan_batch_update requires args.rules[] or args.find");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return raw.map((rule, index) => {
|
|
453
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
454
|
+
throw new CliError(`rules[${index}] must be an object`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const find = String(rule.find || "");
|
|
458
|
+
if (!find) {
|
|
459
|
+
throw new CliError(`rules[${index}].find is required`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const field = rule.field || "both";
|
|
463
|
+
if (!["title", "text", "both"].includes(field)) {
|
|
464
|
+
throw new CliError(`rules[${index}].field must be title|text|both`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const caseSensitive = !!rule.caseSensitive;
|
|
468
|
+
const wholeWord = !!rule.wholeWord;
|
|
469
|
+
const all = rule.all !== false;
|
|
470
|
+
const flags = `${all ? "g" : ""}${caseSensitive ? "" : "i"}`;
|
|
471
|
+
const source = wholeWord ? `\\b${escapeRegex(find)}\\b` : escapeRegex(find);
|
|
472
|
+
const regex = new RegExp(source, flags || (caseSensitive ? "" : "i"));
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
field,
|
|
476
|
+
find,
|
|
477
|
+
replace: String(rule.replace ?? ""),
|
|
478
|
+
caseSensitive,
|
|
479
|
+
wholeWord,
|
|
480
|
+
all,
|
|
481
|
+
regex,
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function normalizeTerminologyRuleEntries(args) {
|
|
487
|
+
const rawGlossary = Array.isArray(args.glossary) ? args.glossary : null;
|
|
488
|
+
const rawMapCandidates = [args.map, args.glossaryMap, args.terminologyMap];
|
|
489
|
+
const rawMap = rawMapCandidates.find(
|
|
490
|
+
(candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate)
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const rules = [];
|
|
494
|
+
let inputMode = "";
|
|
495
|
+
|
|
496
|
+
if (rawGlossary && rawGlossary.length > 0) {
|
|
497
|
+
inputMode = "glossary";
|
|
498
|
+
for (let index = 0; index < rawGlossary.length; index += 1) {
|
|
499
|
+
const entry = rawGlossary[index];
|
|
500
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
501
|
+
throw new CliError(`glossary[${index}] must be an object`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const from = String(entry.from ?? entry.find ?? "").trim();
|
|
505
|
+
if (!from) {
|
|
506
|
+
throw new CliError(`glossary[${index}].from (or find) is required`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const to = String(entry.to ?? entry.replace ?? "");
|
|
510
|
+
const field = entry.field || "both";
|
|
511
|
+
if (!["title", "text", "both"].includes(field)) {
|
|
512
|
+
throw new CliError(`glossary[${index}].field must be title|text|both`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
rules.push({
|
|
516
|
+
field,
|
|
517
|
+
find: from,
|
|
518
|
+
replace: to,
|
|
519
|
+
caseSensitive: entry.caseSensitive,
|
|
520
|
+
wholeWord: entry.wholeWord,
|
|
521
|
+
all: entry.all,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
} else if (rawMap && Object.keys(rawMap).length > 0) {
|
|
525
|
+
inputMode = "map";
|
|
526
|
+
const keys = Object.keys(rawMap).sort((a, b) => a.localeCompare(b));
|
|
527
|
+
for (const fromKey of keys) {
|
|
528
|
+
const from = String(fromKey || "").trim();
|
|
529
|
+
if (!from) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
rules.push({
|
|
533
|
+
field: "both",
|
|
534
|
+
find: from,
|
|
535
|
+
replace: String(rawMap[fromKey] ?? ""),
|
|
536
|
+
caseSensitive: !!args.caseSensitive,
|
|
537
|
+
wholeWord: args.wholeWord,
|
|
538
|
+
all: args.all,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
throw new CliError("documents.plan_terminology_refactor requires args.glossary[] or args.map object");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const deduped = [];
|
|
546
|
+
const seen = new Set();
|
|
547
|
+
for (const rule of rules) {
|
|
548
|
+
const key = [
|
|
549
|
+
rule.field || "both",
|
|
550
|
+
String(rule.find || ""),
|
|
551
|
+
String(rule.replace ?? ""),
|
|
552
|
+
rule.caseSensitive ? "1" : "0",
|
|
553
|
+
rule.wholeWord ? "1" : "0",
|
|
554
|
+
rule.all === false ? "0" : "1",
|
|
555
|
+
].join("::");
|
|
556
|
+
if (seen.has(key)) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
seen.add(key);
|
|
560
|
+
deduped.push(rule);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
inputMode,
|
|
565
|
+
rules: deduped,
|
|
566
|
+
glossary: deduped.map((rule) => ({
|
|
567
|
+
from: rule.find,
|
|
568
|
+
to: String(rule.replace ?? ""),
|
|
569
|
+
field: rule.field || "both",
|
|
570
|
+
caseSensitive: !!rule.caseSensitive,
|
|
571
|
+
wholeWord: !!rule.wholeWord,
|
|
572
|
+
all: rule.all !== false,
|
|
573
|
+
})),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function countMatches(text, regex) {
|
|
578
|
+
const flags = regex.flags.includes("g") ? regex.flags : `${regex.flags}g`;
|
|
579
|
+
const counter = new RegExp(regex.source, flags);
|
|
580
|
+
const matches = String(text || "").match(counter);
|
|
581
|
+
return matches ? matches.length : 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function applyPlanRulesToDocument(doc, rules) {
|
|
585
|
+
const currentTitle = String(doc?.title || "");
|
|
586
|
+
const currentText = String(doc?.text || "");
|
|
587
|
+
let nextTitle = currentTitle;
|
|
588
|
+
let nextText = currentText;
|
|
589
|
+
|
|
590
|
+
const replacements = {
|
|
591
|
+
title: 0,
|
|
592
|
+
text: 0,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
for (const rule of rules) {
|
|
596
|
+
if (rule.field === "title" || rule.field === "both") {
|
|
597
|
+
const count = countMatches(nextTitle, rule.regex);
|
|
598
|
+
if (count > 0) {
|
|
599
|
+
nextTitle = nextTitle.replace(rule.regex, rule.replace);
|
|
600
|
+
replacements.title += count;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (rule.field === "text" || rule.field === "both") {
|
|
605
|
+
const count = countMatches(nextText, rule.regex);
|
|
606
|
+
if (count > 0) {
|
|
607
|
+
nextText = nextText.replace(rule.regex, rule.replace);
|
|
608
|
+
replacements.text += count;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
nextTitle,
|
|
615
|
+
nextText,
|
|
616
|
+
titleChanged: nextTitle !== currentTitle,
|
|
617
|
+
textChanged: nextText !== currentText,
|
|
618
|
+
replacements: {
|
|
619
|
+
...replacements,
|
|
620
|
+
total: replacements.title + replacements.text,
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function normalizeQueryInputs(args) {
|
|
626
|
+
const queries = [];
|
|
627
|
+
const ids = [];
|
|
628
|
+
|
|
629
|
+
if (Array.isArray(args.ids)) {
|
|
630
|
+
for (const id of args.ids) {
|
|
631
|
+
if (id != null) {
|
|
632
|
+
ids.push(String(id));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
} else if (args.id) {
|
|
636
|
+
ids.push(String(args.id));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (Array.isArray(args.queries)) {
|
|
640
|
+
for (const query of args.queries) {
|
|
641
|
+
if (query != null && String(query).trim()) {
|
|
642
|
+
queries.push(String(query).trim());
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (args.query && String(args.query).trim()) {
|
|
647
|
+
queries.push(String(args.query).trim());
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const uniqueIds = [...new Set(ids)];
|
|
651
|
+
const uniqueQueries = [...new Set(queries)];
|
|
652
|
+
if (uniqueIds.length === 0 && uniqueQueries.length === 0) {
|
|
653
|
+
throw new CliError("documents.plan_batch_update requires ids[]/id or query/queries[]");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
ids: uniqueIds,
|
|
658
|
+
queries: uniqueQueries,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function findPlanCandidateIds(ctx, args, queries, maxAttempts) {
|
|
663
|
+
const set = new Set();
|
|
664
|
+
const limitPerQuery = Math.max(1, toInteger(args.limitPerQuery, 10));
|
|
665
|
+
const offset = Math.max(0, toInteger(args.offset, 0));
|
|
666
|
+
const includeTitleSearch = args.includeTitleSearch !== false;
|
|
667
|
+
const includeSemanticSearch = args.includeSemanticSearch !== false;
|
|
668
|
+
if (!includeTitleSearch && !includeSemanticSearch) {
|
|
669
|
+
throw new CliError("documents.plan_batch_update requires includeTitleSearch or includeSemanticSearch");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
await mapLimit(queries, Math.max(1, toInteger(args.concurrency, 4)), async (query) => {
|
|
673
|
+
const base = compactValue({
|
|
674
|
+
query,
|
|
675
|
+
collectionId: args.collectionId,
|
|
676
|
+
limit: limitPerQuery,
|
|
677
|
+
offset,
|
|
678
|
+
}) || {};
|
|
679
|
+
|
|
680
|
+
if (includeTitleSearch) {
|
|
681
|
+
const titleRes = await ctx.client.call("documents.search_titles", base, { maxAttempts });
|
|
682
|
+
const rows = Array.isArray(titleRes.body?.data) ? titleRes.body.data : [];
|
|
683
|
+
for (const row of rows) {
|
|
684
|
+
if (row?.id) {
|
|
685
|
+
set.add(String(row.id));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (includeSemanticSearch) {
|
|
691
|
+
const semanticRes = await ctx.client.call(
|
|
692
|
+
"documents.search",
|
|
693
|
+
{
|
|
694
|
+
...base,
|
|
695
|
+
snippetMinWords: toInteger(args.snippetMinWords, 16),
|
|
696
|
+
snippetMaxWords: toInteger(args.snippetMaxWords, 24),
|
|
697
|
+
},
|
|
698
|
+
{ maxAttempts }
|
|
699
|
+
);
|
|
700
|
+
const rows = Array.isArray(semanticRes.body?.data) ? semanticRes.body.data : [];
|
|
701
|
+
for (const row of rows) {
|
|
702
|
+
const id = row?.document?.id;
|
|
703
|
+
if (id) {
|
|
704
|
+
set.add(String(id));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return [...set];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function loadPlanDocs(ctx, ids, maxAttempts, concurrency) {
|
|
714
|
+
const loaded = await mapLimit(ids, Math.max(1, concurrency), async (id) => {
|
|
715
|
+
try {
|
|
716
|
+
const info = await ctx.client.call("documents.info", { id }, { maxAttempts });
|
|
717
|
+
return {
|
|
718
|
+
id,
|
|
719
|
+
ok: true,
|
|
720
|
+
data: info.body?.data || null,
|
|
721
|
+
};
|
|
722
|
+
} catch (err) {
|
|
723
|
+
if (err instanceof ApiError) {
|
|
724
|
+
return {
|
|
725
|
+
id,
|
|
726
|
+
ok: false,
|
|
727
|
+
error: err.message,
|
|
728
|
+
status: err.details.status,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
throw err;
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
return loaded;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function documentsPlanBatchUpdateTool(ctx, args) {
|
|
739
|
+
const rules = normalizePlanRules(args);
|
|
740
|
+
const maxAttempts = toInteger(args.maxAttempts, 2);
|
|
741
|
+
const readConcurrency = Math.max(1, toInteger(args.readConcurrency, 4));
|
|
742
|
+
const maxDocuments = Math.max(1, toInteger(args.maxDocuments, 30));
|
|
743
|
+
const includeUnchanged = !!args.includeUnchanged;
|
|
744
|
+
|
|
745
|
+
const normalized = normalizeQueryInputs(args);
|
|
746
|
+
const discoveredIds =
|
|
747
|
+
normalized.queries.length > 0
|
|
748
|
+
? await findPlanCandidateIds(ctx, args, normalized.queries, maxAttempts)
|
|
749
|
+
: [];
|
|
750
|
+
const candidateIds = [...new Set([...normalized.ids, ...discoveredIds])].slice(0, maxDocuments);
|
|
751
|
+
|
|
752
|
+
const loaded = await loadPlanDocs(ctx, candidateIds, maxAttempts, readConcurrency);
|
|
753
|
+
const docs = loaded.filter((row) => row.ok && row.data).map((row) => row.data);
|
|
754
|
+
const loadFailures = loaded.filter((row) => !row.ok);
|
|
755
|
+
|
|
756
|
+
const impacts = [];
|
|
757
|
+
const planItems = [];
|
|
758
|
+
const hunkLimit = Math.max(1, toInteger(args.hunkLimit, 8));
|
|
759
|
+
const hunkLineLimit = Math.max(1, toInteger(args.hunkLineLimit, 10));
|
|
760
|
+
|
|
761
|
+
for (const doc of docs) {
|
|
762
|
+
const applied = applyPlanRulesToDocument(doc, rules);
|
|
763
|
+
const changed = applied.titleChanged || applied.textChanged;
|
|
764
|
+
if (!changed && !includeUnchanged) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const textDiff = applied.textChanged ? computeLineDiff(doc.text || "", applied.nextText) : null;
|
|
769
|
+
const summary = {
|
|
770
|
+
id: doc.id,
|
|
771
|
+
title: doc.title,
|
|
772
|
+
revision: doc.revision,
|
|
773
|
+
changed,
|
|
774
|
+
titleChanged: applied.titleChanged,
|
|
775
|
+
textChanged: applied.textChanged,
|
|
776
|
+
replacementCounts: applied.replacements,
|
|
777
|
+
proposedTitle: applied.titleChanged ? applied.nextTitle : undefined,
|
|
778
|
+
stats: textDiff?.stats,
|
|
779
|
+
hunks: textDiff ? previewHunks(textDiff.hunks, hunkLimit, hunkLineLimit) : [],
|
|
780
|
+
truncated: !!textDiff,
|
|
781
|
+
};
|
|
782
|
+
impacts.push(summary);
|
|
783
|
+
|
|
784
|
+
if (changed) {
|
|
785
|
+
planItems.push(
|
|
786
|
+
compactValue({
|
|
787
|
+
id: doc.id,
|
|
788
|
+
expectedRevision: Number(doc.revision),
|
|
789
|
+
title: applied.titleChanged ? applied.nextTitle : undefined,
|
|
790
|
+
text: applied.textChanged ? applied.nextText : undefined,
|
|
791
|
+
})
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
impacts.sort((a, b) => {
|
|
797
|
+
const aChanged = a.changed ? 1 : 0;
|
|
798
|
+
const bChanged = b.changed ? 1 : 0;
|
|
799
|
+
if (bChanged !== aChanged) {
|
|
800
|
+
return bChanged - aChanged;
|
|
801
|
+
}
|
|
802
|
+
const aTotal = Number(a.replacementCounts?.total || 0);
|
|
803
|
+
const bTotal = Number(b.replacementCounts?.total || 0);
|
|
804
|
+
if (bTotal !== aTotal) {
|
|
805
|
+
return bTotal - aTotal;
|
|
806
|
+
}
|
|
807
|
+
return String(a.title || "").localeCompare(String(b.title || ""));
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const plan = {
|
|
811
|
+
version: 1,
|
|
812
|
+
createdAt: new Date().toISOString(),
|
|
813
|
+
source: {
|
|
814
|
+
ids: normalized.ids,
|
|
815
|
+
queries: normalized.queries,
|
|
816
|
+
collectionId: args.collectionId,
|
|
817
|
+
},
|
|
818
|
+
rules: rules.map((rule) => ({
|
|
819
|
+
field: rule.field,
|
|
820
|
+
find: rule.find,
|
|
821
|
+
replace: rule.replace,
|
|
822
|
+
caseSensitive: rule.caseSensitive,
|
|
823
|
+
wholeWord: rule.wholeWord,
|
|
824
|
+
all: rule.all,
|
|
825
|
+
})),
|
|
826
|
+
items: planItems,
|
|
827
|
+
};
|
|
828
|
+
const normalizedPlan = compactValue(plan) || {};
|
|
829
|
+
const planHash = hashPlanObject(normalizedPlan);
|
|
830
|
+
const changedCount = planItems.length;
|
|
831
|
+
const unchangedCount = impacts.filter((row) => !row.changed).length;
|
|
832
|
+
const totalReplacementCount = impacts.reduce((acc, row) => acc + Number(row.replacementCounts?.total || 0), 0);
|
|
833
|
+
|
|
834
|
+
return {
|
|
835
|
+
tool: "documents.plan_batch_update",
|
|
836
|
+
profile: ctx.profile.id,
|
|
837
|
+
result: {
|
|
838
|
+
ok: true,
|
|
839
|
+
planHash,
|
|
840
|
+
candidateCount: candidateIds.length,
|
|
841
|
+
loadedCount: docs.length,
|
|
842
|
+
loadFailedCount: loadFailures.length,
|
|
843
|
+
changedCount,
|
|
844
|
+
unchangedCount,
|
|
845
|
+
totalReplacementCount,
|
|
846
|
+
impacts,
|
|
847
|
+
loadFailures,
|
|
848
|
+
plan: normalizedPlan,
|
|
849
|
+
next: {
|
|
850
|
+
tool: "documents.apply_batch_plan",
|
|
851
|
+
args: {
|
|
852
|
+
confirmHash: planHash,
|
|
853
|
+
plan: normalizedPlan,
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function documentsPlanTerminologyRefactorTool(ctx, args) {
|
|
861
|
+
const normalizedGlossary = normalizeTerminologyRuleEntries(args || {});
|
|
862
|
+
const planArgs = compactValue({
|
|
863
|
+
id: args.id,
|
|
864
|
+
ids: args.ids,
|
|
865
|
+
query: args.query,
|
|
866
|
+
queries: args.queries,
|
|
867
|
+
collectionId: args.collectionId,
|
|
868
|
+
rules: normalizedGlossary.rules,
|
|
869
|
+
includeTitleSearch: args.includeTitleSearch,
|
|
870
|
+
includeSemanticSearch: args.includeSemanticSearch,
|
|
871
|
+
limitPerQuery: args.limitPerQuery,
|
|
872
|
+
offset: args.offset,
|
|
873
|
+
maxDocuments: args.maxDocuments,
|
|
874
|
+
readConcurrency: args.readConcurrency,
|
|
875
|
+
includeUnchanged: args.includeUnchanged,
|
|
876
|
+
hunkLimit: args.hunkLimit,
|
|
877
|
+
hunkLineLimit: args.hunkLineLimit,
|
|
878
|
+
maxAttempts: args.maxAttempts,
|
|
879
|
+
}) || {};
|
|
880
|
+
|
|
881
|
+
const planned = await documentsPlanBatchUpdateTool(ctx, planArgs);
|
|
882
|
+
return {
|
|
883
|
+
tool: "documents.plan_terminology_refactor",
|
|
884
|
+
profile: ctx.profile.id,
|
|
885
|
+
result: {
|
|
886
|
+
...(planned.result || {}),
|
|
887
|
+
metadata: {
|
|
888
|
+
sourceTool: "documents.plan_batch_update",
|
|
889
|
+
inputMode: normalizedGlossary.inputMode,
|
|
890
|
+
glossaryCount: normalizedGlossary.glossary.length,
|
|
891
|
+
glossary: normalizedGlossary.glossary,
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function documentsApplyBatchPlanTool(ctx, args) {
|
|
898
|
+
if (!args.plan || typeof args.plan !== "object" || Array.isArray(args.plan)) {
|
|
899
|
+
throw new CliError("documents.apply_batch_plan requires args.plan object");
|
|
900
|
+
}
|
|
901
|
+
if (!args.confirmHash || typeof args.confirmHash !== "string") {
|
|
902
|
+
throw new CliError("documents.apply_batch_plan requires args.confirmHash");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const incomingPlan = { ...args.plan };
|
|
906
|
+
delete incomingPlan.planHash;
|
|
907
|
+
const expectedHash = hashPlanObject(incomingPlan);
|
|
908
|
+
if (expectedHash !== args.confirmHash) {
|
|
909
|
+
throw new CliError("plan hash mismatch; regenerate/confirm latest plan", {
|
|
910
|
+
code: "PLAN_HASH_MISMATCH",
|
|
911
|
+
expectedHash,
|
|
912
|
+
providedHash: args.confirmHash,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const items = Array.isArray(incomingPlan.items) ? incomingPlan.items : [];
|
|
917
|
+
if (items.length === 0) {
|
|
918
|
+
return {
|
|
919
|
+
tool: "documents.apply_batch_plan",
|
|
920
|
+
profile: ctx.profile.id,
|
|
921
|
+
result: {
|
|
922
|
+
ok: true,
|
|
923
|
+
applied: false,
|
|
924
|
+
reason: "empty_plan_items",
|
|
925
|
+
total: 0,
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const dryRun = !!args.dryRun;
|
|
931
|
+
if (!dryRun) {
|
|
932
|
+
assertPerformAction(args, {
|
|
933
|
+
tool: "documents.apply_batch_plan",
|
|
934
|
+
action: "apply planned document updates",
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
const continueOnError = args.continueOnError !== false;
|
|
938
|
+
const concurrency = continueOnError ? Math.max(1, toInteger(args.concurrency, 3)) : 1;
|
|
939
|
+
const maxAttempts = toInteger(args.maxAttempts, 1);
|
|
940
|
+
|
|
941
|
+
const runner = async (item, index) => {
|
|
942
|
+
try {
|
|
943
|
+
if (!item || typeof item !== "object") {
|
|
944
|
+
throw new CliError(`plan.items[${index}] must be an object`);
|
|
945
|
+
}
|
|
946
|
+
if (!item.id || typeof item.id !== "string") {
|
|
947
|
+
throw new CliError(`plan.items[${index}].id is required`);
|
|
948
|
+
}
|
|
949
|
+
if (!Number.isFinite(Number(item.expectedRevision))) {
|
|
950
|
+
throw new CliError(`plan.items[${index}].expectedRevision must be a number`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (dryRun) {
|
|
954
|
+
return {
|
|
955
|
+
index,
|
|
956
|
+
id: item.id,
|
|
957
|
+
ok: true,
|
|
958
|
+
result: {
|
|
959
|
+
ok: true,
|
|
960
|
+
updated: false,
|
|
961
|
+
dryRun: true,
|
|
962
|
+
id: item.id,
|
|
963
|
+
expectedRevision: Number(item.expectedRevision),
|
|
964
|
+
},
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const safeArgs = compactValue({
|
|
969
|
+
id: item.id,
|
|
970
|
+
expectedRevision: Number(item.expectedRevision),
|
|
971
|
+
title: Object.prototype.hasOwnProperty.call(item, "title") ? item.title : undefined,
|
|
972
|
+
text: Object.prototype.hasOwnProperty.call(item, "text") ? item.text : undefined,
|
|
973
|
+
editMode: "replace",
|
|
974
|
+
performAction: true,
|
|
975
|
+
view: args.view || "summary",
|
|
976
|
+
excerptChars: args.excerptChars,
|
|
977
|
+
maxAttempts,
|
|
978
|
+
}) || {};
|
|
979
|
+
|
|
980
|
+
const safe = await documentsSafeUpdateTool(ctx, safeArgs);
|
|
981
|
+
return {
|
|
982
|
+
index,
|
|
983
|
+
id: item.id,
|
|
984
|
+
ok: safe.result?.ok === true,
|
|
985
|
+
result: safe.result,
|
|
986
|
+
};
|
|
987
|
+
} catch (err) {
|
|
988
|
+
if (err instanceof ApiError || err instanceof CliError) {
|
|
989
|
+
return {
|
|
990
|
+
index,
|
|
991
|
+
id: item?.id,
|
|
992
|
+
ok: false,
|
|
993
|
+
result: {
|
|
994
|
+
ok: false,
|
|
995
|
+
updated: false,
|
|
996
|
+
id: item?.id,
|
|
997
|
+
error: {
|
|
998
|
+
code: err instanceof ApiError ? "api_error" : "invalid_plan_item",
|
|
999
|
+
message: err.message,
|
|
1000
|
+
...(err.details || {}),
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
throw err;
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const results = [];
|
|
1010
|
+
if (!continueOnError) {
|
|
1011
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
1012
|
+
const out = await runner(items[i], i);
|
|
1013
|
+
results.push(out);
|
|
1014
|
+
if (!out.ok) {
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
const out = await mapLimit(items, concurrency, runner);
|
|
1020
|
+
results.push(...out);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const failed = results.filter((item) => !item.ok).length;
|
|
1024
|
+
|
|
1025
|
+
return {
|
|
1026
|
+
tool: "documents.apply_batch_plan",
|
|
1027
|
+
profile: ctx.profile.id,
|
|
1028
|
+
result: {
|
|
1029
|
+
ok: failed === 0,
|
|
1030
|
+
dryRun,
|
|
1031
|
+
total: results.length,
|
|
1032
|
+
succeeded: results.length - failed,
|
|
1033
|
+
failed,
|
|
1034
|
+
continueOnError,
|
|
1035
|
+
confirmHash: args.confirmHash,
|
|
1036
|
+
items: results,
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function documentsDeleteTool(ctx, args) {
|
|
1042
|
+
if (!args.id) {
|
|
1043
|
+
throw new CliError("documents.delete requires args.id");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
assertPerformAction(args, {
|
|
1047
|
+
tool: "documents.delete",
|
|
1048
|
+
action: "delete a document",
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
const maxAttempts = toInteger(args.maxAttempts, 1);
|
|
1052
|
+
const receipt = await getDocumentDeleteReadReceipt({
|
|
1053
|
+
token: args.readToken,
|
|
1054
|
+
profileId: ctx.profile.id,
|
|
1055
|
+
documentId: args.id,
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
const latest = await ctx.client.call("documents.info", { id: args.id }, {
|
|
1059
|
+
maxAttempts: Math.max(1, maxAttempts),
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const expectedRevision = Number(receipt.revision);
|
|
1063
|
+
const actualRevision = Number(latest.body?.data?.revision);
|
|
1064
|
+
if (
|
|
1065
|
+
Number.isFinite(expectedRevision) &&
|
|
1066
|
+
Number.isFinite(actualRevision) &&
|
|
1067
|
+
actualRevision !== expectedRevision
|
|
1068
|
+
) {
|
|
1069
|
+
throw new CliError("Delete read confirmation is stale; re-read document with armDelete=true", {
|
|
1070
|
+
code: "DELETE_READ_TOKEN_STALE",
|
|
1071
|
+
id: args.id,
|
|
1072
|
+
expectedRevision,
|
|
1073
|
+
actualRevision,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const deleted = await ctx.client.call("documents.delete", { id: args.id }, { maxAttempts });
|
|
1078
|
+
if (deleted.body?.success !== false) {
|
|
1079
|
+
await consumeDocumentDeleteReadReceipt(args.readToken);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return {
|
|
1083
|
+
tool: "documents.delete",
|
|
1084
|
+
profile: ctx.profile.id,
|
|
1085
|
+
result: {
|
|
1086
|
+
ok: true,
|
|
1087
|
+
deleted: true,
|
|
1088
|
+
id: args.id,
|
|
1089
|
+
success: deleted.body?.success !== false,
|
|
1090
|
+
expectedRevision: Number.isFinite(expectedRevision) ? expectedRevision : undefined,
|
|
1091
|
+
actualRevision: Number.isFinite(actualRevision) ? actualRevision : undefined,
|
|
1092
|
+
usedReadToken: true,
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function documentsSafeUpdateTool(ctx, args) {
|
|
1098
|
+
if (!args.id) {
|
|
1099
|
+
throw new CliError("documents.safe_update requires args.id");
|
|
1100
|
+
}
|
|
1101
|
+
if (args.expectedRevision === undefined || args.expectedRevision === null) {
|
|
1102
|
+
throw new CliError("documents.safe_update requires args.expectedRevision");
|
|
1103
|
+
}
|
|
1104
|
+
assertPerformAction(args, {
|
|
1105
|
+
tool: "documents.safe_update",
|
|
1106
|
+
action: "update a document",
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
const expectedRevision = Number(args.expectedRevision);
|
|
1110
|
+
if (!Number.isFinite(expectedRevision)) {
|
|
1111
|
+
throw new CliError("expectedRevision must be a number");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const info = await ctx.client.call("documents.info", { id: args.id }, {
|
|
1115
|
+
maxAttempts: toInteger(args.maxAttempts, 2),
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
const current = info.body?.data;
|
|
1119
|
+
const actualRevision = Number(current?.revision);
|
|
1120
|
+
|
|
1121
|
+
if (actualRevision !== expectedRevision) {
|
|
1122
|
+
return {
|
|
1123
|
+
tool: "documents.safe_update",
|
|
1124
|
+
profile: ctx.profile.id,
|
|
1125
|
+
result: buildRevisionConflict({
|
|
1126
|
+
id: args.id,
|
|
1127
|
+
expectedRevision,
|
|
1128
|
+
actualRevision,
|
|
1129
|
+
}),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const updateBody = ensureUpdatePayload(args);
|
|
1134
|
+
const updated = await ctx.client.call("documents.update", updateBody, {
|
|
1135
|
+
maxAttempts: toInteger(args.maxAttempts, 1),
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
return {
|
|
1139
|
+
tool: "documents.safe_update",
|
|
1140
|
+
profile: ctx.profile.id,
|
|
1141
|
+
result: {
|
|
1142
|
+
ok: true,
|
|
1143
|
+
updated: true,
|
|
1144
|
+
id: args.id,
|
|
1145
|
+
previousRevision: actualRevision,
|
|
1146
|
+
currentRevision: updated.body?.data?.revision,
|
|
1147
|
+
data: normalizeDocumentSummary(updated.body?.data, args.view || "summary", toInteger(args.excerptChars, 220)),
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function documentsDiffTool(ctx, args) {
|
|
1153
|
+
if (!args.id) {
|
|
1154
|
+
throw new CliError("documents.diff requires args.id");
|
|
1155
|
+
}
|
|
1156
|
+
if (typeof args.proposedText !== "string") {
|
|
1157
|
+
throw new CliError("documents.diff requires args.proposedText as string");
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const info = await ctx.client.call("documents.info", { id: args.id }, {
|
|
1161
|
+
maxAttempts: toInteger(args.maxAttempts, 2),
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
const current = info.body?.data;
|
|
1165
|
+
const currentText = current?.text || "";
|
|
1166
|
+
const diff = computeLineDiff(currentText, args.proposedText);
|
|
1167
|
+
const payload = buildDiffPayload(diff, args);
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
tool: "documents.diff",
|
|
1171
|
+
profile: ctx.profile.id,
|
|
1172
|
+
result: {
|
|
1173
|
+
ok: true,
|
|
1174
|
+
id: args.id,
|
|
1175
|
+
revision: current?.revision,
|
|
1176
|
+
title: current?.title,
|
|
1177
|
+
stats: payload.stats,
|
|
1178
|
+
hunks: payload.hunks,
|
|
1179
|
+
truncated: payload.truncated,
|
|
1180
|
+
},
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function revisionsDiffTool(ctx, args) {
|
|
1185
|
+
if (!args.id) {
|
|
1186
|
+
throw new CliError("revisions.diff requires args.id");
|
|
1187
|
+
}
|
|
1188
|
+
if (!args.baseRevisionId) {
|
|
1189
|
+
throw new CliError("revisions.diff requires args.baseRevisionId");
|
|
1190
|
+
}
|
|
1191
|
+
if (!args.targetRevisionId) {
|
|
1192
|
+
throw new CliError("revisions.diff requires args.targetRevisionId");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const maxAttempts = toInteger(args.maxAttempts, 2);
|
|
1196
|
+
const [baseRes, targetRes] = await Promise.all([
|
|
1197
|
+
ctx.client.call("revisions.info", { id: args.baseRevisionId }, { maxAttempts }),
|
|
1198
|
+
ctx.client.call("revisions.info", { id: args.targetRevisionId }, { maxAttempts }),
|
|
1199
|
+
]);
|
|
1200
|
+
|
|
1201
|
+
const baseRevision = baseRes.body?.data;
|
|
1202
|
+
const targetRevision = targetRes.body?.data;
|
|
1203
|
+
|
|
1204
|
+
if (!baseRevision || typeof baseRevision !== "object") {
|
|
1205
|
+
throw new CliError("revisions.diff could not hydrate baseRevisionId via revisions.info");
|
|
1206
|
+
}
|
|
1207
|
+
if (!targetRevision || typeof targetRevision !== "object") {
|
|
1208
|
+
throw new CliError("revisions.diff could not hydrate targetRevisionId via revisions.info");
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const baseDocumentId = resolveRevisionDocumentId(baseRevision);
|
|
1212
|
+
const targetDocumentId = resolveRevisionDocumentId(targetRevision);
|
|
1213
|
+
if (baseDocumentId && baseDocumentId !== args.id) {
|
|
1214
|
+
throw new CliError("revisions.diff base revision does not belong to args.id", {
|
|
1215
|
+
code: "REVISION_DOCUMENT_MISMATCH",
|
|
1216
|
+
id: args.id,
|
|
1217
|
+
baseRevisionId: args.baseRevisionId,
|
|
1218
|
+
revisionDocumentId: baseDocumentId,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if (targetDocumentId && targetDocumentId !== args.id) {
|
|
1222
|
+
throw new CliError("revisions.diff target revision does not belong to args.id", {
|
|
1223
|
+
code: "REVISION_DOCUMENT_MISMATCH",
|
|
1224
|
+
id: args.id,
|
|
1225
|
+
targetRevisionId: args.targetRevisionId,
|
|
1226
|
+
revisionDocumentId: targetDocumentId,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const diff = computeLineDiff(resolveRevisionText(baseRevision), resolveRevisionText(targetRevision));
|
|
1231
|
+
const payload = buildDiffPayload(diff, args);
|
|
1232
|
+
const view = args.view === "full" ? "full" : "summary";
|
|
1233
|
+
|
|
1234
|
+
return {
|
|
1235
|
+
tool: "revisions.diff",
|
|
1236
|
+
profile: ctx.profile.id,
|
|
1237
|
+
result: {
|
|
1238
|
+
ok: true,
|
|
1239
|
+
id: args.id,
|
|
1240
|
+
baseRevisionId: args.baseRevisionId,
|
|
1241
|
+
targetRevisionId: args.targetRevisionId,
|
|
1242
|
+
baseRevision: normalizeRevisionDiffMeta(baseRevision, view),
|
|
1243
|
+
targetRevision: normalizeRevisionDiffMeta(targetRevision, view),
|
|
1244
|
+
stats: payload.stats,
|
|
1245
|
+
hunks: payload.hunks,
|
|
1246
|
+
truncated: payload.truncated,
|
|
1247
|
+
},
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function documentsApplyPatchTool(ctx, args, options = {}) {
|
|
1252
|
+
const toolName = options.toolName || "documents.apply_patch";
|
|
1253
|
+
const requireExpectedRevision = options.requireExpectedRevision === true;
|
|
1254
|
+
|
|
1255
|
+
if (!args.id) {
|
|
1256
|
+
throw new CliError(`${toolName} requires args.id`);
|
|
1257
|
+
}
|
|
1258
|
+
if (typeof args.patch !== "string") {
|
|
1259
|
+
throw new CliError(`${toolName} requires args.patch as string`);
|
|
1260
|
+
}
|
|
1261
|
+
assertPerformAction(args, {
|
|
1262
|
+
tool: toolName,
|
|
1263
|
+
action: "apply a document patch",
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
const mode = args.mode === "replace" ? "replace" : "unified";
|
|
1267
|
+
const hasExpectedRevision = Object.prototype.hasOwnProperty.call(args, "expectedRevision");
|
|
1268
|
+
if (requireExpectedRevision && !hasExpectedRevision) {
|
|
1269
|
+
throw new CliError(`${toolName} requires args.expectedRevision`);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const info = await ctx.client.call("documents.info", { id: args.id }, {
|
|
1273
|
+
maxAttempts: toInteger(args.maxAttempts, 2),
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
const current = info.body?.data;
|
|
1277
|
+
const currentText = current?.text || "";
|
|
1278
|
+
const previousRevision = Number(current?.revision);
|
|
1279
|
+
const expectedRevision = hasExpectedRevision ? Number(args.expectedRevision) : undefined;
|
|
1280
|
+
if (hasExpectedRevision && !Number.isFinite(expectedRevision)) {
|
|
1281
|
+
throw new CliError("expectedRevision must be a number");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const actualRevision = Number.isFinite(previousRevision) ? previousRevision : undefined;
|
|
1285
|
+
if (hasExpectedRevision && actualRevision !== expectedRevision) {
|
|
1286
|
+
return {
|
|
1287
|
+
tool: toolName,
|
|
1288
|
+
profile: ctx.profile.id,
|
|
1289
|
+
result: {
|
|
1290
|
+
...buildRevisionConflict({
|
|
1291
|
+
id: args.id,
|
|
1292
|
+
expectedRevision,
|
|
1293
|
+
actualRevision,
|
|
1294
|
+
}),
|
|
1295
|
+
mode,
|
|
1296
|
+
previousRevision: actualRevision,
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
let nextText = args.patch;
|
|
1302
|
+
if (mode === "unified") {
|
|
1303
|
+
const applied = applyUnifiedPatch(currentText, args.patch);
|
|
1304
|
+
if (!applied.ok) {
|
|
1305
|
+
return {
|
|
1306
|
+
tool: toolName,
|
|
1307
|
+
profile: ctx.profile.id,
|
|
1308
|
+
result: {
|
|
1309
|
+
ok: false,
|
|
1310
|
+
updated: false,
|
|
1311
|
+
id: args.id,
|
|
1312
|
+
mode,
|
|
1313
|
+
previousRevision,
|
|
1314
|
+
error: applied.error,
|
|
1315
|
+
},
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
nextText = applied.text;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const updateBody = ensureUpdatePayload({
|
|
1322
|
+
...args,
|
|
1323
|
+
id: args.id,
|
|
1324
|
+
text: nextText,
|
|
1325
|
+
editMode: "replace",
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
const updated = await ctx.client.call("documents.update", updateBody, {
|
|
1329
|
+
maxAttempts: toInteger(args.maxAttempts, 1),
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
return {
|
|
1333
|
+
tool: toolName,
|
|
1334
|
+
profile: ctx.profile.id,
|
|
1335
|
+
result: {
|
|
1336
|
+
ok: true,
|
|
1337
|
+
updated: true,
|
|
1338
|
+
id: args.id,
|
|
1339
|
+
mode,
|
|
1340
|
+
previousRevision,
|
|
1341
|
+
currentRevision: updated.body?.data?.revision,
|
|
1342
|
+
data: normalizeDocumentSummary(updated.body?.data, args.view || "summary", toInteger(args.excerptChars, 220)),
|
|
1343
|
+
},
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function documentsApplyPatchSafeTool(ctx, args) {
|
|
1348
|
+
return documentsApplyPatchTool(ctx, args, {
|
|
1349
|
+
toolName: "documents.apply_patch_safe",
|
|
1350
|
+
requireExpectedRevision: true,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function documentsBatchUpdateTool(ctx, args) {
|
|
1355
|
+
const updates = Array.isArray(args.updates) ? args.updates : null;
|
|
1356
|
+
if (!updates || updates.length === 0) {
|
|
1357
|
+
throw new CliError("documents.batch_update requires args.updates[]");
|
|
1358
|
+
}
|
|
1359
|
+
assertPerformAction(args, {
|
|
1360
|
+
tool: "documents.batch_update",
|
|
1361
|
+
action: "perform batch document updates",
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
const continueOnError = args.continueOnError !== false;
|
|
1365
|
+
const concurrency = continueOnError ? toInteger(args.concurrency, 4) : 1;
|
|
1366
|
+
const maxAttempts = toInteger(args.maxAttempts, 1);
|
|
1367
|
+
|
|
1368
|
+
const items = [];
|
|
1369
|
+
|
|
1370
|
+
const runner = async (update, index) => {
|
|
1371
|
+
try {
|
|
1372
|
+
if (!update || typeof update !== "object" || !update.id) {
|
|
1373
|
+
throw new CliError(`updates[${index}] must include id`);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const body = ensureUpdatePayload(update);
|
|
1377
|
+
|
|
1378
|
+
if (Object.prototype.hasOwnProperty.call(update, "expectedRevision")) {
|
|
1379
|
+
const safe = await documentsSafeUpdateTool(ctx, {
|
|
1380
|
+
...update,
|
|
1381
|
+
performAction: true,
|
|
1382
|
+
maxAttempts,
|
|
1383
|
+
view: "summary",
|
|
1384
|
+
});
|
|
1385
|
+
return {
|
|
1386
|
+
index,
|
|
1387
|
+
id: update.id,
|
|
1388
|
+
ok: safe.result?.ok === true,
|
|
1389
|
+
result: safe.result,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const updated = await ctx.client.call("documents.update", body, { maxAttempts });
|
|
1394
|
+
return {
|
|
1395
|
+
index,
|
|
1396
|
+
id: update.id,
|
|
1397
|
+
ok: true,
|
|
1398
|
+
result: {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
updated: true,
|
|
1401
|
+
id: update.id,
|
|
1402
|
+
revision: updated.body?.data?.revision,
|
|
1403
|
+
data: normalizeDocumentSummary(updated.body?.data, update.view || "summary"),
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
if (err instanceof ApiError || err instanceof CliError) {
|
|
1408
|
+
return {
|
|
1409
|
+
index,
|
|
1410
|
+
id: update?.id,
|
|
1411
|
+
ok: false,
|
|
1412
|
+
result: {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
updated: false,
|
|
1415
|
+
id: update?.id,
|
|
1416
|
+
error: {
|
|
1417
|
+
code: err instanceof ApiError ? "api_error" : "invalid_input",
|
|
1418
|
+
message: err.message,
|
|
1419
|
+
...(err.details || {}),
|
|
1420
|
+
},
|
|
1421
|
+
},
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
throw err;
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
if (!continueOnError) {
|
|
1429
|
+
for (let i = 0; i < updates.length; i += 1) {
|
|
1430
|
+
const item = await runner(updates[i], i);
|
|
1431
|
+
items.push(item);
|
|
1432
|
+
if (!item.ok) {
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
const out = await mapLimit(updates, concurrency, runner);
|
|
1438
|
+
items.push(...out);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const failed = items.filter((item) => !item.ok).length;
|
|
1442
|
+
|
|
1443
|
+
return {
|
|
1444
|
+
tool: "documents.batch_update",
|
|
1445
|
+
profile: ctx.profile.id,
|
|
1446
|
+
result: {
|
|
1447
|
+
ok: failed === 0,
|
|
1448
|
+
total: items.length,
|
|
1449
|
+
succeeded: items.length - failed,
|
|
1450
|
+
failed,
|
|
1451
|
+
continueOnError,
|
|
1452
|
+
items,
|
|
1453
|
+
},
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function normalizeRevisionRow(row, view = "summary") {
|
|
1458
|
+
if (view === "full") {
|
|
1459
|
+
return row;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return {
|
|
1463
|
+
id: row.id,
|
|
1464
|
+
documentId: row.documentId,
|
|
1465
|
+
title: row.title,
|
|
1466
|
+
createdAt: row.createdAt,
|
|
1467
|
+
createdBy: row.createdBy
|
|
1468
|
+
? {
|
|
1469
|
+
id: row.createdBy.id,
|
|
1470
|
+
name: row.createdBy.name,
|
|
1471
|
+
}
|
|
1472
|
+
: undefined,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function revisionsListTool(ctx, args) {
|
|
1477
|
+
if (!args.documentId) {
|
|
1478
|
+
throw new CliError("revisions.list requires args.documentId");
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const body = compactValue({
|
|
1482
|
+
documentId: args.documentId,
|
|
1483
|
+
limit: toInteger(args.limit, 20),
|
|
1484
|
+
offset: toInteger(args.offset, 0),
|
|
1485
|
+
sort: args.sort,
|
|
1486
|
+
direction: args.direction,
|
|
1487
|
+
}) || {};
|
|
1488
|
+
|
|
1489
|
+
const res = await ctx.client.call("revisions.list", body, {
|
|
1490
|
+
maxAttempts: toInteger(args.maxAttempts, 2),
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
const view = args.view || "summary";
|
|
1494
|
+
const rows = Array.isArray(res.body?.data) ? res.body.data.map((row) => normalizeRevisionRow(row, view)) : [];
|
|
1495
|
+
|
|
1496
|
+
const payload = {
|
|
1497
|
+
...res.body,
|
|
1498
|
+
data: rows,
|
|
1499
|
+
revisionCount: rows.length,
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
return {
|
|
1503
|
+
tool: "revisions.list",
|
|
1504
|
+
profile: ctx.profile.id,
|
|
1505
|
+
result: payload,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
async function revisionsRestoreTool(ctx, args) {
|
|
1510
|
+
if (!args.id) {
|
|
1511
|
+
throw new CliError("revisions.restore requires args.id");
|
|
1512
|
+
}
|
|
1513
|
+
if (!args.revisionId) {
|
|
1514
|
+
throw new CliError("revisions.restore requires args.revisionId");
|
|
1515
|
+
}
|
|
1516
|
+
assertPerformAction(args, {
|
|
1517
|
+
tool: "revisions.restore",
|
|
1518
|
+
action: "restore a document revision",
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
const body = compactValue({
|
|
1522
|
+
id: args.id,
|
|
1523
|
+
revisionId: args.revisionId,
|
|
1524
|
+
collectionId: args.collectionId,
|
|
1525
|
+
}) || {};
|
|
1526
|
+
|
|
1527
|
+
const res = await ctx.client.call("documents.restore", body, {
|
|
1528
|
+
maxAttempts: toInteger(args.maxAttempts, 1),
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
return {
|
|
1532
|
+
tool: "revisions.restore",
|
|
1533
|
+
profile: ctx.profile.id,
|
|
1534
|
+
result: {
|
|
1535
|
+
ok: true,
|
|
1536
|
+
id: args.id,
|
|
1537
|
+
revisionId: args.revisionId,
|
|
1538
|
+
data: normalizeDocumentSummary(res.body?.data, args.view || "summary", toInteger(args.excerptChars, 220)),
|
|
1539
|
+
},
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
export const MUTATION_TOOLS = {
|
|
1544
|
+
"documents.safe_update": {
|
|
1545
|
+
signature:
|
|
1546
|
+
"documents.safe_update(args: { id: string; expectedRevision: number; title?: string; text?: string; editMode?: 'replace'|'append'|'prepend'; icon?: string; color?: string; fullWidth?: boolean; templateId?: string; collectionId?: string; insightsEnabled?: boolean; publish?: boolean; dataAttributes?: any[]; view?: 'summary'|'full'; performAction?: boolean })",
|
|
1547
|
+
description: "Update document only if current revision matches expectedRevision.",
|
|
1548
|
+
usageExample: {
|
|
1549
|
+
tool: "documents.safe_update",
|
|
1550
|
+
args: {
|
|
1551
|
+
id: "doc-id",
|
|
1552
|
+
expectedRevision: 3,
|
|
1553
|
+
text: "\n\n## Changes\n- added new action",
|
|
1554
|
+
editMode: "append",
|
|
1555
|
+
},
|
|
1556
|
+
},
|
|
1557
|
+
bestPractices: [
|
|
1558
|
+
"Read document first and pass returned revision as expectedRevision.",
|
|
1559
|
+
"Handle revision_conflict deterministically and re-read before retry.",
|
|
1560
|
+
"Use append/prepend for low-token incremental writes.",
|
|
1561
|
+
"This tool is action-gated; set performAction=true only after explicit confirmation.",
|
|
1562
|
+
],
|
|
1563
|
+
handler: documentsSafeUpdateTool,
|
|
1564
|
+
},
|
|
1565
|
+
"documents.diff": {
|
|
1566
|
+
signature:
|
|
1567
|
+
"documents.diff(args: { id: string; proposedText: string; includeFullHunks?: boolean; hunkLimit?: number; hunkLineLimit?: number })",
|
|
1568
|
+
description: "Compute line-level diff between current document text and proposed text.",
|
|
1569
|
+
usageExample: {
|
|
1570
|
+
tool: "documents.diff",
|
|
1571
|
+
args: {
|
|
1572
|
+
id: "doc-id",
|
|
1573
|
+
proposedText: "# Title\n\nUpdated body",
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
bestPractices: [
|
|
1577
|
+
"Run diff before patch/apply to reduce accidental destructive edits.",
|
|
1578
|
+
"Use preview hunks first; request full hunks only when needed.",
|
|
1579
|
+
"Track added/removed counts to detect large unintended changes.",
|
|
1580
|
+
],
|
|
1581
|
+
handler: documentsDiffTool,
|
|
1582
|
+
},
|
|
1583
|
+
"documents.apply_patch": {
|
|
1584
|
+
signature:
|
|
1585
|
+
"documents.apply_patch(args: { id: string; patch: string; mode?: 'unified'|'replace'; expectedRevision?: number; title?: string; view?: 'summary'|'full'; performAction?: boolean })",
|
|
1586
|
+
description: "Apply unified diff patch (or full replace) to a document and persist update.",
|
|
1587
|
+
usageExample: {
|
|
1588
|
+
tool: "documents.apply_patch",
|
|
1589
|
+
args: {
|
|
1590
|
+
id: "doc-id",
|
|
1591
|
+
expectedRevision: 7,
|
|
1592
|
+
mode: "unified",
|
|
1593
|
+
patch: "@@ -1,1 +1,1 @@\n-Old\n+New",
|
|
1594
|
+
},
|
|
1595
|
+
},
|
|
1596
|
+
bestPractices: [
|
|
1597
|
+
"Prefer unified mode for minimal, auditable text changes.",
|
|
1598
|
+
"Pass expectedRevision when coordinating concurrent editors to prevent stale writes.",
|
|
1599
|
+
"On patch_apply_failed, re-read latest text and regenerate patch.",
|
|
1600
|
+
"Use replace mode only for full-document rewrites.",
|
|
1601
|
+
"This tool is action-gated; set performAction=true only after explicit confirmation.",
|
|
1602
|
+
],
|
|
1603
|
+
handler: documentsApplyPatchTool,
|
|
1604
|
+
},
|
|
1605
|
+
"documents.apply_patch_safe": {
|
|
1606
|
+
signature:
|
|
1607
|
+
"documents.apply_patch_safe(args: { id: string; expectedRevision: number; patch: string; mode?: 'unified'|'replace'; title?: string; view?: 'summary'|'full'; performAction?: boolean })",
|
|
1608
|
+
description:
|
|
1609
|
+
"Apply patch with mandatory revision guard so writes only proceed from the expected document revision.",
|
|
1610
|
+
usageExample: {
|
|
1611
|
+
tool: "documents.apply_patch_safe",
|
|
1612
|
+
args: {
|
|
1613
|
+
id: "doc-id",
|
|
1614
|
+
expectedRevision: 7,
|
|
1615
|
+
mode: "unified",
|
|
1616
|
+
patch: "@@ -1,1 +1,1 @@\n-Old\n+New",
|
|
1617
|
+
},
|
|
1618
|
+
},
|
|
1619
|
+
bestPractices: [
|
|
1620
|
+
"Use this wrapper for all automated patch writes to enforce optimistic concurrency.",
|
|
1621
|
+
"Re-read the document and regenerate patch when revision_conflict is returned.",
|
|
1622
|
+
"Keep unified mode for minimal, auditable edits; use replace mode for full rewrites only.",
|
|
1623
|
+
"This tool is action-gated; set performAction=true only after explicit confirmation.",
|
|
1624
|
+
],
|
|
1625
|
+
handler: documentsApplyPatchSafeTool,
|
|
1626
|
+
},
|
|
1627
|
+
"documents.batch_update": {
|
|
1628
|
+
signature:
|
|
1629
|
+
"documents.batch_update(args: { updates: Array<{ id: string; expectedRevision?: number; title?: string; text?: string; editMode?: 'replace'|'append'|'prepend' }>; concurrency?: number; continueOnError?: boolean; performAction?: boolean })",
|
|
1630
|
+
description: "Run multiple document updates in one call with per-item results.",
|
|
1631
|
+
usageExample: {
|
|
1632
|
+
tool: "documents.batch_update",
|
|
1633
|
+
args: {
|
|
1634
|
+
updates: [
|
|
1635
|
+
{ id: "doc-1", text: "\n\nupdate one", editMode: "append" },
|
|
1636
|
+
{ id: "doc-2", title: "Renamed" },
|
|
1637
|
+
],
|
|
1638
|
+
concurrency: 2,
|
|
1639
|
+
continueOnError: true,
|
|
1640
|
+
},
|
|
1641
|
+
},
|
|
1642
|
+
bestPractices: [
|
|
1643
|
+
"Use expectedRevision per update for multi-agent safety.",
|
|
1644
|
+
"Set continueOnError=false for transactional-style stop-on-first-failure flows.",
|
|
1645
|
+
"Use per-item results to retry only failed updates.",
|
|
1646
|
+
"This tool is action-gated; set performAction=true only after explicit confirmation.",
|
|
1647
|
+
],
|
|
1648
|
+
handler: documentsBatchUpdateTool,
|
|
1649
|
+
},
|
|
1650
|
+
"documents.delete": {
|
|
1651
|
+
signature:
|
|
1652
|
+
"documents.delete(args: { id: string; readToken: string; performAction?: boolean; maxAttempts?: number })",
|
|
1653
|
+
description:
|
|
1654
|
+
"Delete a document with mandatory read-confirmation token and action gate.",
|
|
1655
|
+
usageExample: {
|
|
1656
|
+
tool: "documents.delete",
|
|
1657
|
+
args: {
|
|
1658
|
+
id: "doc-id",
|
|
1659
|
+
readToken: "<token from documents.info armDelete=true>",
|
|
1660
|
+
performAction: true,
|
|
1661
|
+
},
|
|
1662
|
+
},
|
|
1663
|
+
bestPractices: [
|
|
1664
|
+
"Always read with documents.info armDelete=true immediately before delete.",
|
|
1665
|
+
"Delete tokens are profile-bound and revision-sensitive; re-read on stale token errors.",
|
|
1666
|
+
"Keep performAction=false by default in automation and set true only for the final confirmed call.",
|
|
1667
|
+
],
|
|
1668
|
+
handler: documentsDeleteTool,
|
|
1669
|
+
},
|
|
1670
|
+
"documents.plan_batch_update": {
|
|
1671
|
+
signature:
|
|
1672
|
+
"documents.plan_batch_update(args: { id?: string; ids?: string[]; query?: string; queries?: string[]; collectionId?: string; rules?: Array<{ field?: 'title'|'text'|'both'; find: string; replace?: string; caseSensitive?: boolean; wholeWord?: boolean; all?: boolean }>; includeTitleSearch?: boolean; includeSemanticSearch?: boolean; limitPerQuery?: number; offset?: number; maxDocuments?: number; readConcurrency?: number; includeUnchanged?: boolean; hunkLimit?: number; hunkLineLimit?: number; maxAttempts?: number; })",
|
|
1673
|
+
description:
|
|
1674
|
+
"Plan multi-document refactors/renames by previewing affected docs, replacement counts, and text hunks before applying.",
|
|
1675
|
+
usageExample: {
|
|
1676
|
+
tool: "documents.plan_batch_update",
|
|
1677
|
+
args: {
|
|
1678
|
+
query: "incident communication",
|
|
1679
|
+
rules: [
|
|
1680
|
+
{
|
|
1681
|
+
field: "both",
|
|
1682
|
+
find: "SEV1",
|
|
1683
|
+
replace: "SEV-1",
|
|
1684
|
+
wholeWord: true,
|
|
1685
|
+
},
|
|
1686
|
+
],
|
|
1687
|
+
maxDocuments: 20,
|
|
1688
|
+
},
|
|
1689
|
+
},
|
|
1690
|
+
bestPractices: [
|
|
1691
|
+
"Review `impacts` with the user before applying changes.",
|
|
1692
|
+
"Use precise rules (wholeWord/caseSensitive) to avoid broad unintended edits.",
|
|
1693
|
+
"Apply with `documents.apply_batch_plan` using the returned `planHash` for explicit confirmation.",
|
|
1694
|
+
],
|
|
1695
|
+
handler: documentsPlanBatchUpdateTool,
|
|
1696
|
+
},
|
|
1697
|
+
"documents.plan_terminology_refactor": {
|
|
1698
|
+
signature:
|
|
1699
|
+
"documents.plan_terminology_refactor(args: { glossary?: Array<{ from?: string; to?: string; find?: string; replace?: string; field?: 'title'|'text'|'both'; caseSensitive?: boolean; wholeWord?: boolean; all?: boolean }>; map?: Record<string, string>; glossaryMap?: Record<string, string>; terminologyMap?: Record<string, string>; id?: string; ids?: string[]; query?: string; queries?: string[]; collectionId?: string; includeTitleSearch?: boolean; includeSemanticSearch?: boolean; limitPerQuery?: number; offset?: number; maxDocuments?: number; readConcurrency?: number; includeUnchanged?: boolean; hunkLimit?: number; hunkLineLimit?: number; maxAttempts?: number })",
|
|
1700
|
+
description:
|
|
1701
|
+
"Plan terminology refactors using glossary/map inputs and return plan_batch_update-compatible output with metadata.",
|
|
1702
|
+
usageExample: {
|
|
1703
|
+
tool: "documents.plan_terminology_refactor",
|
|
1704
|
+
args: {
|
|
1705
|
+
queries: ["incident response", "escalation policy"],
|
|
1706
|
+
glossary: [
|
|
1707
|
+
{ from: "SEV1", to: "SEV-1", field: "both", wholeWord: true },
|
|
1708
|
+
{ from: "on call", to: "on-call", field: "text" },
|
|
1709
|
+
],
|
|
1710
|
+
maxDocuments: 25,
|
|
1711
|
+
},
|
|
1712
|
+
},
|
|
1713
|
+
bestPractices: [
|
|
1714
|
+
"Use glossary[] when each mapping needs its own field/casing controls.",
|
|
1715
|
+
"Use map for fast one-to-one terminology upgrades across both title and text.",
|
|
1716
|
+
"Review returned impacts/planHash before applying with documents.apply_batch_plan.",
|
|
1717
|
+
],
|
|
1718
|
+
handler: documentsPlanTerminologyRefactorTool,
|
|
1719
|
+
},
|
|
1720
|
+
"documents.apply_batch_plan": {
|
|
1721
|
+
signature:
|
|
1722
|
+
"documents.apply_batch_plan(args: { plan: object; confirmHash: string; dryRun?: boolean; continueOnError?: boolean; concurrency?: number; view?: 'summary'|'full'; maxAttempts?: number; performAction?: boolean; })",
|
|
1723
|
+
description:
|
|
1724
|
+
"Apply a previously generated batch-update plan with hash confirmation and revision-safe updates.",
|
|
1725
|
+
usageExample: {
|
|
1726
|
+
tool: "documents.apply_batch_plan",
|
|
1727
|
+
args: {
|
|
1728
|
+
confirmHash: "sha256-hash-from-plan",
|
|
1729
|
+
plan: {
|
|
1730
|
+
version: 1,
|
|
1731
|
+
items: [
|
|
1732
|
+
{
|
|
1733
|
+
id: "doc-id",
|
|
1734
|
+
expectedRevision: 12,
|
|
1735
|
+
title: "Renamed title",
|
|
1736
|
+
},
|
|
1737
|
+
],
|
|
1738
|
+
},
|
|
1739
|
+
},
|
|
1740
|
+
},
|
|
1741
|
+
bestPractices: [
|
|
1742
|
+
"Require explicit user confirmation of `planHash` before execution.",
|
|
1743
|
+
"Keep `dryRun=true` for one final verification step in automation loops.",
|
|
1744
|
+
"Treat `revision_conflict` results as a re-plan signal, not an auto-retry.",
|
|
1745
|
+
"When dryRun=false this tool is action-gated; set performAction=true only after explicit confirmation.",
|
|
1746
|
+
],
|
|
1747
|
+
handler: documentsApplyBatchPlanTool,
|
|
1748
|
+
},
|
|
1749
|
+
"revisions.list": {
|
|
1750
|
+
signature:
|
|
1751
|
+
"revisions.list(args: { documentId: string; limit?: number; offset?: number; sort?: string; direction?: 'ASC'|'DESC'; view?: 'summary'|'full' })",
|
|
1752
|
+
description: "List revisions for a document.",
|
|
1753
|
+
usageExample: {
|
|
1754
|
+
tool: "revisions.list",
|
|
1755
|
+
args: {
|
|
1756
|
+
documentId: "doc-id",
|
|
1757
|
+
limit: 10,
|
|
1758
|
+
view: "summary",
|
|
1759
|
+
},
|
|
1760
|
+
},
|
|
1761
|
+
bestPractices: [
|
|
1762
|
+
"Use small limits and paginate for long histories.",
|
|
1763
|
+
"Capture revision IDs before performing restore operations.",
|
|
1764
|
+
"Use summary view for fast planning loops.",
|
|
1765
|
+
],
|
|
1766
|
+
handler: revisionsListTool,
|
|
1767
|
+
},
|
|
1768
|
+
"revisions.diff": {
|
|
1769
|
+
signature:
|
|
1770
|
+
"revisions.diff(args: { id: string; baseRevisionId: string; targetRevisionId: string; includeFullHunks?: boolean; hunkLimit?: number; hunkLineLimit?: number; view?: 'summary'|'full'; maxAttempts?: number })",
|
|
1771
|
+
description: "Compute line-level diff between two revisions by hydrating both with revisions.info.",
|
|
1772
|
+
usageExample: {
|
|
1773
|
+
tool: "revisions.diff",
|
|
1774
|
+
args: {
|
|
1775
|
+
id: "doc-id",
|
|
1776
|
+
baseRevisionId: "revision-id-older",
|
|
1777
|
+
targetRevisionId: "revision-id-newer",
|
|
1778
|
+
view: "summary",
|
|
1779
|
+
},
|
|
1780
|
+
},
|
|
1781
|
+
bestPractices: [
|
|
1782
|
+
"Pass adjacent revisions first to isolate rollback root causes.",
|
|
1783
|
+
"Use preview hunks first; set includeFullHunks=true only when needed.",
|
|
1784
|
+
"Verify revision metadata before applying restore or patch actions.",
|
|
1785
|
+
],
|
|
1786
|
+
handler: revisionsDiffTool,
|
|
1787
|
+
},
|
|
1788
|
+
"revisions.restore": {
|
|
1789
|
+
signature:
|
|
1790
|
+
"revisions.restore(args: { id: string; revisionId: string; collectionId?: string; view?: 'summary'|'full'; performAction?: boolean })",
|
|
1791
|
+
description: "Restore a document to a specific revision using documents.restore endpoint.",
|
|
1792
|
+
usageExample: {
|
|
1793
|
+
tool: "revisions.restore",
|
|
1794
|
+
args: {
|
|
1795
|
+
id: "doc-id",
|
|
1796
|
+
revisionId: "revision-id",
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
bestPractices: [
|
|
1800
|
+
"List revisions first and confirm target revision id.",
|
|
1801
|
+
"Use on test/sandbox docs before applying to important docs.",
|
|
1802
|
+
"Capture post-restore revision for subsequent safe updates.",
|
|
1803
|
+
"This tool is action-gated; set performAction=true only after explicit confirmation.",
|
|
1804
|
+
],
|
|
1805
|
+
handler: revisionsRestoreTool,
|
|
1806
|
+
},
|
|
1807
|
+
};
|