@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.
@@ -0,0 +1,3252 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ApiError, CliError } from "./errors.js";
4
+ import { assertPerformAction } from "./action-gate.js";
5
+ import { compactValue, ensureStringArray, mapLimit, toInteger } from "./utils.js";
6
+
7
+ const CONTROL_ARG_KEYS = new Set([
8
+ "performAction",
9
+ "maxAttempts",
10
+ "includePolicies",
11
+ "concurrency",
12
+ "question",
13
+ "questions",
14
+ "compact",
15
+ ]);
16
+
17
+ function maybeDropPolicies(payload, includePolicies) {
18
+ if (includePolicies) {
19
+ return payload;
20
+ }
21
+ if (payload && typeof payload === "object" && "policies" in payload) {
22
+ const clone = { ...payload };
23
+ delete clone.policies;
24
+ return clone;
25
+ }
26
+ return payload;
27
+ }
28
+
29
+ function buildBody(args = {}, omit = []) {
30
+ const omitSet = new Set([...CONTROL_ARG_KEYS, ...omit]);
31
+ const body = {};
32
+ for (const [key, value] of Object.entries(args || {})) {
33
+ if (omitSet.has(key) || value === undefined) {
34
+ continue;
35
+ }
36
+ body[key] = value;
37
+ }
38
+ return compactValue(body) || {};
39
+ }
40
+
41
+ function appendMultipartValue(form, key, value) {
42
+ if (value === undefined) {
43
+ return;
44
+ }
45
+ if (value instanceof Blob) {
46
+ form.append(key, value);
47
+ return;
48
+ }
49
+ if (Array.isArray(value) || (value && typeof value === "object")) {
50
+ form.append(key, JSON.stringify(value));
51
+ return;
52
+ }
53
+ form.append(key, String(value));
54
+ }
55
+
56
+ function defaultUsageArgs(def) {
57
+ if (def.tool === "documents.empty_trash") {
58
+ return def.mutating ? { performAction: true } : {};
59
+ }
60
+ if (def.tool === "users.invite") {
61
+ return {
62
+ email: "new.user@example.com",
63
+ role: "member",
64
+ performAction: true,
65
+ };
66
+ }
67
+ if (def.tool === "users.update_role") {
68
+ return {
69
+ id: "user-id",
70
+ role: "member",
71
+ performAction: true,
72
+ };
73
+ }
74
+ if (def.tool === "shares.create") {
75
+ return {
76
+ documentId: "document-id",
77
+ performAction: true,
78
+ };
79
+ }
80
+ if (def.tool === "shares.update") {
81
+ return {
82
+ id: "share-id",
83
+ published: true,
84
+ performAction: true,
85
+ };
86
+ }
87
+ if (def.tool === "shares.revoke") {
88
+ return {
89
+ id: "share-id",
90
+ performAction: true,
91
+ };
92
+ }
93
+ if (def.tool === "documents.import") {
94
+ return {
95
+ collectionId: "collection-id",
96
+ data: {},
97
+ performAction: true,
98
+ };
99
+ }
100
+ if (def.tool === "oauth_clients.create") {
101
+ return {
102
+ name: "Example OAuth App",
103
+ redirectUris: ["https://example.com/callback"],
104
+ performAction: true,
105
+ };
106
+ }
107
+ if (def.tool === "oauth_clients.update") {
108
+ return {
109
+ id: "oauth-client-id",
110
+ name: "Updated OAuth App",
111
+ performAction: true,
112
+ };
113
+ }
114
+ if (
115
+ def.tool === "oauth_clients.rotate_secret" ||
116
+ def.tool === "oauth_clients.delete" ||
117
+ def.tool === "oauthClients.delete"
118
+ ) {
119
+ return {
120
+ id: "oauth-client-id",
121
+ performAction: true,
122
+ };
123
+ }
124
+ if (
125
+ def.tool === "oauth_authentications.delete" ||
126
+ def.tool === "oauthAuthentications.delete"
127
+ ) {
128
+ return {
129
+ oauthClientId: "oauth-client-id",
130
+ performAction: true,
131
+ };
132
+ }
133
+ if (
134
+ def.tool.endsWith(".list") ||
135
+ def.tool.endsWith(".archived") ||
136
+ def.tool.endsWith(".deleted") ||
137
+ def.tool.endsWith(".memberships") ||
138
+ def.tool.endsWith(".group_memberships")
139
+ ) {
140
+ return {};
141
+ }
142
+ if (def.tool.endsWith(".add_user") || def.tool.endsWith(".remove_user")) {
143
+ return {
144
+ id: "resource-id",
145
+ userId: "user-id",
146
+ ...(def.mutating ? { performAction: true } : {}),
147
+ };
148
+ }
149
+ if (def.tool.endsWith(".add_group") || def.tool.endsWith(".remove_group")) {
150
+ return {
151
+ id: "resource-id",
152
+ groupId: "group-id",
153
+ ...(def.mutating ? { performAction: true } : {}),
154
+ };
155
+ }
156
+ return {
157
+ id: "id",
158
+ ...(def.mutating ? { performAction: true } : {}),
159
+ };
160
+ }
161
+
162
+ function makeRpcHandler(def) {
163
+ return async function rpcHandler(ctx, args = {}) {
164
+ if (def.mutating) {
165
+ assertPerformAction(args, {
166
+ tool: def.tool,
167
+ action: `invoke mutating method '${def.method}'`,
168
+ });
169
+ }
170
+
171
+ const maxAttempts = toInteger(args.maxAttempts, def.mutating ? 1 : 2);
172
+ const body = buildBody(args);
173
+ let res;
174
+ try {
175
+ res = await ctx.client.call(def.method, body, { maxAttempts });
176
+ } catch (err) {
177
+ // Some Outline deployments require comments.update payload in data.text form.
178
+ if (
179
+ def.tool === "comments.update"
180
+ && err instanceof ApiError
181
+ && err.details?.status === 400
182
+ && /data/i.test(String(err.message || ""))
183
+ && typeof args?.text === "string"
184
+ && args?.text.length > 0
185
+ && (args?.data === undefined || args?.data === null)
186
+ ) {
187
+ const fallbackBody = buildBody({
188
+ ...args,
189
+ text: undefined,
190
+ data: { text: args.text },
191
+ });
192
+ res = await ctx.client.call(def.method, fallbackBody, { maxAttempts });
193
+ } else {
194
+ throw err;
195
+ }
196
+ }
197
+
198
+ return {
199
+ tool: def.tool,
200
+ profile: ctx.profile.id,
201
+ result: maybeDropPolicies(res.body, !!args.includePolicies),
202
+ };
203
+ };
204
+ }
205
+
206
+ function makeRpcContract(def) {
207
+ return {
208
+ signature: `${def.tool}(args?: { ...endpointArgs; includePolicies?: boolean; maxAttempts?: number${
209
+ def.mutating ? "; performAction?: boolean" : ""
210
+ } })`,
211
+ description: def.description,
212
+ usageExample: {
213
+ tool: def.tool,
214
+ args: defaultUsageArgs(def),
215
+ },
216
+ bestPractices: [
217
+ "Prefer minimal payloads to keep responses deterministic and token-efficient.",
218
+ ...(def.mutating
219
+ ? ["This tool is action-gated; set performAction=true only for explicitly confirmed mutations."]
220
+ : ["Use includePolicies=true only when policy details are required."]),
221
+ ],
222
+ handler: makeRpcHandler(def),
223
+ };
224
+ }
225
+
226
+ const RPC_WRAPPER_DEFS = [
227
+ { tool: "shares.list", method: "shares.list", description: "List shares." },
228
+ { tool: "shares.info", method: "shares.info", description: "Get share details." },
229
+ { tool: "shares.create", method: "shares.create", description: "Create a share.", mutating: true },
230
+ { tool: "shares.update", method: "shares.update", description: "Update a share.", mutating: true },
231
+ { tool: "shares.revoke", method: "shares.revoke", description: "Revoke a share.", mutating: true },
232
+ { tool: "templates.list", method: "templates.list", description: "List templates." },
233
+ { tool: "templates.info", method: "templates.info", description: "Get template details." },
234
+ { tool: "templates.create", method: "templates.create", description: "Create a template.", mutating: true },
235
+ { tool: "templates.update", method: "templates.update", description: "Update a template.", mutating: true },
236
+ { tool: "templates.delete", method: "templates.delete", description: "Delete a template.", mutating: true },
237
+ { tool: "templates.restore", method: "templates.restore", description: "Restore a template.", mutating: true },
238
+ { tool: "templates.duplicate", method: "templates.duplicate", description: "Duplicate a template.", mutating: true },
239
+ { tool: "documents.templatize", method: "documents.templatize", description: "Convert a document into a template.", mutating: true },
240
+ { tool: "documents.import", method: "documents.import", description: "Import a document from JSON payload.", mutating: true },
241
+ { tool: "comments.list", method: "comments.list", description: "List comments." },
242
+ { tool: "comments.info", method: "comments.info", description: "Get comment details." },
243
+ { tool: "comments.create", method: "comments.create", description: "Create a comment.", mutating: true },
244
+ { tool: "comments.update", method: "comments.update", description: "Update a comment.", mutating: true },
245
+ { tool: "comments.delete", method: "comments.delete", description: "Delete a comment.", mutating: true },
246
+ { tool: "events.list", method: "events.list", description: "List workspace events." },
247
+ { tool: "oauth_clients.list", method: "oauthClients.list", description: "List OAuth clients." },
248
+ { tool: "oauth_clients.info", method: "oauthClients.info", description: "Get OAuth client details." },
249
+ { tool: "oauth_clients.create", method: "oauthClients.create", description: "Create an OAuth client.", mutating: true },
250
+ { tool: "oauth_clients.update", method: "oauthClients.update", description: "Update an OAuth client.", mutating: true },
251
+ {
252
+ tool: "oauth_clients.rotate_secret",
253
+ method: "oauthClients.rotate_secret",
254
+ description: "Rotate an OAuth client secret.",
255
+ mutating: true,
256
+ },
257
+ { tool: "oauth_clients.delete", method: "oauthClients.delete", description: "Delete an OAuth client.", mutating: true },
258
+ {
259
+ tool: "oauth_authentications.list",
260
+ method: "oauthAuthentications.list",
261
+ description: "List OAuth authentications.",
262
+ },
263
+ {
264
+ tool: "oauth_authentications.delete",
265
+ method: "oauthAuthentications.delete",
266
+ description: "Delete an OAuth authentication.",
267
+ mutating: true,
268
+ },
269
+ {
270
+ tool: "oauthClients.delete",
271
+ method: "oauthClients.delete",
272
+ description: "Compatibility alias for oauth_clients.delete.",
273
+ mutating: true,
274
+ },
275
+ {
276
+ tool: "oauthAuthentications.delete",
277
+ method: "oauthAuthentications.delete",
278
+ description: "Compatibility alias for oauth_authentications.delete.",
279
+ mutating: true,
280
+ },
281
+ { tool: "data_attributes.list", method: "dataAttributes.list", description: "List data attributes." },
282
+ { tool: "data_attributes.info", method: "dataAttributes.info", description: "Get data attribute details." },
283
+ { tool: "data_attributes.create", method: "dataAttributes.create", description: "Create a data attribute.", mutating: true },
284
+ { tool: "data_attributes.update", method: "dataAttributes.update", description: "Update a data attribute.", mutating: true },
285
+ { tool: "data_attributes.delete", method: "dataAttributes.delete", description: "Delete a data attribute.", mutating: true },
286
+ { tool: "revisions.info", method: "revisions.info", description: "Get revision details." },
287
+ { tool: "documents.archived", method: "documents.archived", description: "List archived documents." },
288
+ { tool: "documents.deleted", method: "documents.deleted", description: "List deleted documents." },
289
+ { tool: "documents.archive", method: "documents.archive", description: "Archive a document.", mutating: true },
290
+ { tool: "documents.restore", method: "documents.restore", description: "Restore a document.", mutating: true },
291
+ {
292
+ tool: "documents.permanent_delete",
293
+ method: "documents.permanent_delete",
294
+ description: "Permanently delete a document.",
295
+ mutating: true,
296
+ },
297
+ { tool: "documents.empty_trash", method: "documents.empty_trash", description: "Empty document trash.", mutating: true },
298
+ { tool: "webhooks.list", method: "webhooks.list", description: "List webhooks." },
299
+ { tool: "webhooks.info", method: "webhooks.info", description: "Get webhook details." },
300
+ { tool: "webhooks.create", method: "webhooks.create", description: "Create a webhook.", mutating: true },
301
+ { tool: "webhooks.update", method: "webhooks.update", description: "Update a webhook.", mutating: true },
302
+ { tool: "webhooks.delete", method: "webhooks.delete", description: "Delete a webhook.", mutating: true },
303
+ { tool: "users.list", method: "users.list", description: "List users." },
304
+ { tool: "users.info", method: "users.info", description: "Get user details." },
305
+ { tool: "users.invite", method: "users.invite", description: "Invite a user.", mutating: true },
306
+ {
307
+ tool: "users.update_role",
308
+ method: "users.update_role",
309
+ description: "Update a user's workspace role.",
310
+ mutating: true,
311
+ },
312
+ { tool: "users.activate", method: "users.activate", description: "Activate a user.", mutating: true },
313
+ { tool: "users.suspend", method: "users.suspend", description: "Suspend a user.", mutating: true },
314
+ { tool: "groups.list", method: "groups.list", description: "List groups." },
315
+ { tool: "groups.info", method: "groups.info", description: "Get group details." },
316
+ { tool: "groups.memberships", method: "groups.memberships", description: "List group user memberships." },
317
+ { tool: "groups.create", method: "groups.create", description: "Create a group.", mutating: true },
318
+ { tool: "groups.update", method: "groups.update", description: "Update a group.", mutating: true },
319
+ { tool: "groups.delete", method: "groups.delete", description: "Delete a group.", mutating: true },
320
+ { tool: "groups.add_user", method: "groups.add_user", description: "Add a user to a group.", mutating: true },
321
+ { tool: "groups.remove_user", method: "groups.remove_user", description: "Remove a user from a group.", mutating: true },
322
+ { tool: "collections.memberships", method: "collections.memberships", description: "List collection user memberships." },
323
+ { tool: "collections.group_memberships", method: "collections.group_memberships", description: "List collection group memberships." },
324
+ { tool: "collections.add_user", method: "collections.add_user", description: "Add a user to a collection.", mutating: true },
325
+ { tool: "collections.remove_user", method: "collections.remove_user", description: "Remove a user from a collection.", mutating: true },
326
+ { tool: "collections.add_group", method: "collections.add_group", description: "Add a group to a collection.", mutating: true },
327
+ { tool: "collections.remove_group", method: "collections.remove_group", description: "Remove a group from a collection.", mutating: true },
328
+ { tool: "documents.users", method: "documents.users", description: "List users with access to a document." },
329
+ { tool: "documents.memberships", method: "documents.memberships", description: "List document user memberships." },
330
+ { tool: "documents.group_memberships", method: "documents.group_memberships", description: "List document group memberships." },
331
+ { tool: "documents.add_user", method: "documents.add_user", description: "Add a user to a document.", mutating: true },
332
+ { tool: "documents.remove_user", method: "documents.remove_user", description: "Remove a user from a document.", mutating: true },
333
+ { tool: "documents.add_group", method: "documents.add_group", description: "Add a group to a document.", mutating: true },
334
+ { tool: "documents.remove_group", method: "documents.remove_group", description: "Remove a group from a document.", mutating: true },
335
+ { tool: "file_operations.list", method: "fileOperations.list", description: "List file operations." },
336
+ { tool: "file_operations.info", method: "fileOperations.info", description: "Get file operation details." },
337
+ { tool: "file_operations.delete", method: "fileOperations.delete", description: "Delete a file operation.", mutating: true },
338
+ ];
339
+
340
+ const RPC_TOOLS = Object.fromEntries(
341
+ RPC_WRAPPER_DEFS.map((def) => [
342
+ def.tool,
343
+ makeRpcContract({
344
+ ...def,
345
+ mutating: !!def.mutating,
346
+ }),
347
+ ])
348
+ );
349
+
350
+ function parseQuestionItem(raw, index) {
351
+ if (typeof raw === "string") {
352
+ const question = raw.trim();
353
+ if (!question) {
354
+ throw new CliError(`questions[${index}] must not be empty`);
355
+ }
356
+ return {
357
+ question,
358
+ body: {},
359
+ documentId: null,
360
+ };
361
+ }
362
+
363
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
364
+ const question = String(raw.question ?? raw.query ?? "").trim();
365
+ if (!question) {
366
+ throw new CliError(`questions[${index}].question is required`);
367
+ }
368
+ const body = buildBody(raw, ["question", "query"]);
369
+ return {
370
+ question,
371
+ body,
372
+ documentId: body.id || body.documentId || null,
373
+ };
374
+ }
375
+
376
+ throw new CliError(`questions[${index}] must be string or object`);
377
+ }
378
+
379
+ async function documentsAnswerTool(ctx, args = {}) {
380
+ const question = String(args.question ?? args.query ?? "").trim();
381
+ if (!question) {
382
+ throw new CliError("documents.answer requires args.question or args.query");
383
+ }
384
+
385
+ const body = {
386
+ ...buildBody(args, ["question", "query"]),
387
+ query: question,
388
+ };
389
+
390
+ const res = await ctx.client.call("documents.answerQuestion", body, {
391
+ maxAttempts: toInteger(args.maxAttempts, 2),
392
+ });
393
+ const payload = maybeDropPolicies(res.body, !!args.includePolicies);
394
+
395
+ return {
396
+ tool: "documents.answer",
397
+ profile: ctx.profile.id,
398
+ result:
399
+ payload && typeof payload === "object"
400
+ ? { question, ...payload }
401
+ : { question, data: payload },
402
+ };
403
+ }
404
+
405
+ async function documentsAnswerBatchTool(ctx, args = {}) {
406
+ const rawItems = [];
407
+ if (Array.isArray(args.questions)) {
408
+ rawItems.push(...args.questions);
409
+ }
410
+ if (args.question !== undefined || args.query !== undefined) {
411
+ rawItems.unshift(args.question ?? args.query);
412
+ }
413
+
414
+ if (rawItems.length === 0) {
415
+ throw new CliError("documents.answer_batch requires args.question or args.questions[]");
416
+ }
417
+
418
+ const baseBody = buildBody(args, ["question", "questions", "query", "concurrency"]);
419
+ const includePolicies = !!args.includePolicies;
420
+ const maxAttempts = toInteger(args.maxAttempts, 2);
421
+ const concurrency = Math.max(1, Math.min(10, toInteger(args.concurrency, 3)));
422
+
423
+ const items = await mapLimit(rawItems, concurrency, async (raw, index) => {
424
+ let parsed;
425
+ try {
426
+ parsed = parseQuestionItem(raw, index);
427
+ const body = {
428
+ ...baseBody,
429
+ ...parsed.body,
430
+ query: parsed.question,
431
+ };
432
+ const res = await ctx.client.call("documents.answerQuestion", body, {
433
+ maxAttempts,
434
+ });
435
+ const payload = maybeDropPolicies(res.body, includePolicies);
436
+ return {
437
+ index,
438
+ ok: true,
439
+ question: parsed.question,
440
+ documentId: parsed.documentId,
441
+ result: payload,
442
+ };
443
+ } catch (err) {
444
+ if (err instanceof ApiError || err instanceof CliError) {
445
+ return {
446
+ index,
447
+ ok: false,
448
+ question: parsed?.question || (typeof raw === "string" ? raw : undefined),
449
+ documentId: parsed?.documentId || null,
450
+ error: err.message,
451
+ status: err instanceof ApiError ? err.details.status : undefined,
452
+ };
453
+ }
454
+ throw err;
455
+ }
456
+ });
457
+
458
+ const failed = items.filter((item) => !item.ok).length;
459
+
460
+ return {
461
+ tool: "documents.answer_batch",
462
+ profile: ctx.profile.id,
463
+ result: {
464
+ total: items.length,
465
+ succeeded: items.length - failed,
466
+ failed,
467
+ items,
468
+ },
469
+ };
470
+ }
471
+
472
+ async function documentsImportFileTool(ctx, args = {}) {
473
+ assertPerformAction(args, {
474
+ tool: "documents.import_file",
475
+ action: "import a document from local file content",
476
+ });
477
+
478
+ const requestedPath = typeof args.filePath === "string" ? args.filePath.trim() : "";
479
+ if (!requestedPath) {
480
+ throw new CliError("documents.import_file requires args.filePath");
481
+ }
482
+
483
+ const resolvedPath = path.resolve(requestedPath);
484
+ let stat;
485
+ try {
486
+ stat = await fs.stat(resolvedPath);
487
+ } catch (err) {
488
+ if (err && err.code === "ENOENT") {
489
+ throw new CliError(`Import file not found: ${resolvedPath}`, {
490
+ code: "IMPORT_FILE_NOT_FOUND",
491
+ filePath: resolvedPath,
492
+ });
493
+ }
494
+ throw new CliError(`Unable to access import file: ${resolvedPath}`, {
495
+ code: "IMPORT_FILE_ACCESS_FAILED",
496
+ filePath: resolvedPath,
497
+ reason: err?.message || String(err),
498
+ });
499
+ }
500
+
501
+ if (!stat.isFile()) {
502
+ throw new CliError(`Import file path must point to a regular file: ${resolvedPath}`, {
503
+ code: "IMPORT_FILE_INVALID_PATH",
504
+ filePath: resolvedPath,
505
+ });
506
+ }
507
+
508
+ let fileBuffer;
509
+ try {
510
+ fileBuffer = await fs.readFile(resolvedPath);
511
+ } catch (err) {
512
+ throw new CliError(`Unable to read import file: ${resolvedPath}`, {
513
+ code: "IMPORT_FILE_READ_FAILED",
514
+ filePath: resolvedPath,
515
+ reason: err?.message || String(err),
516
+ });
517
+ }
518
+
519
+ const fileName = path.basename(resolvedPath);
520
+ const contentType =
521
+ typeof args.contentType === "string" && args.contentType.trim().length > 0
522
+ ? args.contentType.trim()
523
+ : "application/octet-stream";
524
+
525
+ const form = new FormData();
526
+ form.append("file", new Blob([fileBuffer], { type: contentType }), fileName);
527
+
528
+ const body = buildBody(args, ["filePath", "contentType"]);
529
+ for (const key of Object.keys(body).sort((a, b) => a.localeCompare(b))) {
530
+ appendMultipartValue(form, key, body[key]);
531
+ }
532
+
533
+ const res = await ctx.client.call("documents.import", form, {
534
+ maxAttempts: toInteger(args.maxAttempts, 1),
535
+ bodyType: "multipart",
536
+ });
537
+
538
+ return {
539
+ tool: "documents.import_file",
540
+ profile: ctx.profile.id,
541
+ result: maybeDropPolicies(res.body, !!args.includePolicies),
542
+ };
543
+ }
544
+
545
+ const PLACEHOLDER_TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9._-]+)\s*\}\}/g;
546
+
547
+ function normalizePlaceholderValues(value = {}) {
548
+ if (value === undefined || value === null) {
549
+ return {};
550
+ }
551
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
552
+ throw new CliError("placeholderValues must be an object with string values");
553
+ }
554
+
555
+ const entries = [];
556
+ for (const [rawKey, rawValue] of Object.entries(value)) {
557
+ const key = String(rawKey || "").trim();
558
+ if (!key) {
559
+ throw new CliError("placeholderValues keys must be non-empty strings");
560
+ }
561
+ if (typeof rawValue !== "string") {
562
+ throw new CliError(`placeholderValues.${key} must be a string`);
563
+ }
564
+ entries.push([key, rawValue]);
565
+ }
566
+
567
+ entries.sort(([a], [b]) => compareIdAsc(a, b));
568
+ return Object.fromEntries(entries);
569
+ }
570
+
571
+ function collectTemplateTextNodes(root, output = [], path = "$") {
572
+ if (Array.isArray(root)) {
573
+ for (let i = 0; i < root.length; i += 1) {
574
+ collectTemplateTextNodes(root[i], output, `${path}[${i}]`);
575
+ }
576
+ return output;
577
+ }
578
+
579
+ if (!root || typeof root !== "object") {
580
+ return output;
581
+ }
582
+
583
+ if (root.type === "text" && typeof root.text === "string") {
584
+ output.push({
585
+ path,
586
+ text: root.text,
587
+ });
588
+ }
589
+
590
+ for (const key of Object.keys(root).sort((a, b) => a.localeCompare(b))) {
591
+ if (key === "text") {
592
+ continue;
593
+ }
594
+ collectTemplateTextNodes(root[key], output, `${path}.${key}`);
595
+ }
596
+
597
+ return output;
598
+ }
599
+
600
+ function sortPlaceholderCountRows(countMap) {
601
+ return Array.from(countMap.entries())
602
+ .map(([key, count]) => ({
603
+ key,
604
+ count,
605
+ }))
606
+ .sort((a, b) => compareIdAsc(a.key, b.key));
607
+ }
608
+
609
+ function collectPlaceholderStatsFromTexts(texts = []) {
610
+ const counts = new Map();
611
+ let tokenCount = 0;
612
+ let textNodeCount = 0;
613
+ let scannedCharacterCount = 0;
614
+
615
+ for (const rawText of texts) {
616
+ const text = String(rawText ?? "");
617
+ textNodeCount += 1;
618
+ scannedCharacterCount += text.length;
619
+
620
+ const pattern = new RegExp(PLACEHOLDER_TOKEN_PATTERN.source, "g");
621
+ let match;
622
+ while ((match = pattern.exec(text)) !== null) {
623
+ const key = String(match[1] || "").trim();
624
+ if (!key) {
625
+ continue;
626
+ }
627
+ counts.set(key, (counts.get(key) || 0) + 1);
628
+ tokenCount += 1;
629
+ }
630
+ }
631
+
632
+ const countsByPlaceholder = sortPlaceholderCountRows(counts);
633
+ const placeholders = countsByPlaceholder.map((item) => item.key);
634
+
635
+ return {
636
+ placeholders,
637
+ countsByPlaceholder,
638
+ tokenCount,
639
+ textNodeCount,
640
+ scannedCharacterCount,
641
+ uniquePlaceholderCount: placeholders.length,
642
+ };
643
+ }
644
+
645
+ function replacePlaceholdersInText(text, placeholderValues = {}) {
646
+ const source = String(text ?? "");
647
+ const replacedCounts = new Map();
648
+ const pattern = new RegExp(PLACEHOLDER_TOKEN_PATTERN.source, "g");
649
+
650
+ const replacedText = source.replace(pattern, (token, keyRaw) => {
651
+ const key = String(keyRaw || "").trim();
652
+ if (!Object.prototype.hasOwnProperty.call(placeholderValues, key)) {
653
+ return token;
654
+ }
655
+ replacedCounts.set(key, (replacedCounts.get(key) || 0) + 1);
656
+ return placeholderValues[key];
657
+ });
658
+
659
+ const replacedByPlaceholder = sortPlaceholderCountRows(replacedCounts);
660
+ return {
661
+ text: replacedText,
662
+ replacedByPlaceholder,
663
+ replacedTokenCount: replacedByPlaceholder.reduce((sum, item) => sum + item.count, 0),
664
+ };
665
+ }
666
+
667
+ function normalizeTemplatePipelineView(view) {
668
+ return view === "full" ? "full" : "summary";
669
+ }
670
+
671
+ function normalizeTemplatePipelineDocument(doc, view = "summary") {
672
+ if (view === "full") {
673
+ return doc;
674
+ }
675
+
676
+ return {
677
+ id: doc?.id ? String(doc.id) : "",
678
+ title: doc?.title ? String(doc.title) : "",
679
+ collectionId: doc?.collectionId ? String(doc.collectionId) : "",
680
+ parentDocumentId: doc?.parentDocumentId ? String(doc.parentDocumentId) : "",
681
+ updatedAt: doc?.updatedAt ? String(doc.updatedAt) : "",
682
+ publishedAt: doc?.publishedAt ? String(doc.publishedAt) : "",
683
+ urlId: doc?.urlId ? String(doc.urlId) : "",
684
+ emoji: doc?.emoji ? String(doc.emoji) : "",
685
+ };
686
+ }
687
+
688
+ function stableObject(value) {
689
+ if (Array.isArray(value)) {
690
+ return value.map((item) => stableObject(item));
691
+ }
692
+ if (value && typeof value === "object") {
693
+ const out = {};
694
+ for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b))) {
695
+ out[key] = stableObject(value[key]);
696
+ }
697
+ return out;
698
+ }
699
+ return value;
700
+ }
701
+
702
+ function compactText(value, maxChars = 180) {
703
+ const trimmed = String(value || "").replace(/\s+/g, " ").trim();
704
+ if (!trimmed) {
705
+ return "";
706
+ }
707
+ if (trimmed.length <= maxChars) {
708
+ return trimmed;
709
+ }
710
+ return `${trimmed.slice(0, maxChars)}...`;
711
+ }
712
+
713
+ function normalizeIsoTimestamp(value, label) {
714
+ if (value === undefined || value === null || value === "") {
715
+ return "";
716
+ }
717
+ const parsed = Date.parse(String(value));
718
+ if (!Number.isFinite(parsed)) {
719
+ throw new CliError(`${label} must be a valid ISO date/time string`);
720
+ }
721
+ return new Date(parsed).toISOString();
722
+ }
723
+
724
+ function compareIsoDesc(a, b) {
725
+ return String(b || "").localeCompare(String(a || ""));
726
+ }
727
+
728
+ function compareIdAsc(a, b) {
729
+ return String(a || "").localeCompare(String(b || ""));
730
+ }
731
+
732
+ function uniqueStrings(values = []) {
733
+ const out = [];
734
+ const seen = new Set();
735
+ for (const raw of values) {
736
+ const val = String(raw || "").trim();
737
+ if (!val || seen.has(val)) {
738
+ continue;
739
+ }
740
+ seen.add(val);
741
+ out.push(val);
742
+ }
743
+ return out;
744
+ }
745
+
746
+ function normalizeGraphView(view) {
747
+ if (view === "ids" || view === "full") {
748
+ return view;
749
+ }
750
+ return "summary";
751
+ }
752
+
753
+ function normalizeGraphNode(row, view = "summary") {
754
+ const id = row?.id ? String(row.id) : "";
755
+ if (!id) {
756
+ return null;
757
+ }
758
+
759
+ if (view === "full") {
760
+ return row;
761
+ }
762
+
763
+ const summary = {
764
+ id,
765
+ title: row?.title ? String(row.title) : "",
766
+ collectionId: row?.collectionId ? String(row.collectionId) : "",
767
+ parentDocumentId: row?.parentDocumentId ? String(row.parentDocumentId) : "",
768
+ updatedAt: row?.updatedAt ? String(row.updatedAt) : "",
769
+ publishedAt: row?.publishedAt ? String(row.publishedAt) : "",
770
+ urlId: row?.urlId ? String(row.urlId) : "",
771
+ emoji: row?.emoji ? String(row.emoji) : "",
772
+ };
773
+
774
+ if (view === "ids") {
775
+ return {
776
+ id: summary.id,
777
+ title: summary.title,
778
+ };
779
+ }
780
+
781
+ return summary;
782
+ }
783
+
784
+ function scoreGraphNode(row) {
785
+ const fields = [
786
+ "id",
787
+ "title",
788
+ "collectionId",
789
+ "parentDocumentId",
790
+ "updatedAt",
791
+ "publishedAt",
792
+ "urlId",
793
+ "emoji",
794
+ "text",
795
+ ];
796
+ let score = 0;
797
+ for (const field of fields) {
798
+ if (row?.[field]) {
799
+ score += 1;
800
+ }
801
+ }
802
+ return score;
803
+ }
804
+
805
+ function upsertGraphNode(nodesById, row) {
806
+ const id = row?.id ? String(row.id) : "";
807
+ if (!id) {
808
+ return;
809
+ }
810
+ const existing = nodesById.get(id);
811
+ if (!existing) {
812
+ nodesById.set(id, row);
813
+ return;
814
+ }
815
+
816
+ const candidateScore = scoreGraphNode(row);
817
+ const existingScore = scoreGraphNode(existing);
818
+ if (candidateScore > existingScore) {
819
+ nodesById.set(id, row);
820
+ return;
821
+ }
822
+ if (candidateScore < existingScore) {
823
+ return;
824
+ }
825
+
826
+ if (compareIsoDesc(existing?.updatedAt, row?.updatedAt) > 0) {
827
+ nodesById.set(id, row);
828
+ }
829
+ }
830
+
831
+ function sortGraphEdges(edges = []) {
832
+ return edges.sort((a, b) => {
833
+ const sourceCmp = compareIdAsc(a.sourceId, b.sourceId);
834
+ if (sourceCmp !== 0) {
835
+ return sourceCmp;
836
+ }
837
+ const targetCmp = compareIdAsc(a.targetId, b.targetId);
838
+ if (targetCmp !== 0) {
839
+ return targetCmp;
840
+ }
841
+ const typeCmp = String(a.type || "").localeCompare(String(b.type || ""));
842
+ if (typeCmp !== 0) {
843
+ return typeCmp;
844
+ }
845
+ const queryCmp = String(a.query || "").localeCompare(String(b.query || ""));
846
+ if (queryCmp !== 0) {
847
+ return queryCmp;
848
+ }
849
+ return Number(a.rank || 0) - Number(b.rank || 0);
850
+ });
851
+ }
852
+
853
+ function sortGraphErrors(errors = []) {
854
+ return errors.sort((a, b) => {
855
+ const sourceCmp = compareIdAsc(a.sourceId, b.sourceId);
856
+ if (sourceCmp !== 0) {
857
+ return sourceCmp;
858
+ }
859
+ const typeCmp = String(a.type || "").localeCompare(String(b.type || ""));
860
+ if (typeCmp !== 0) {
861
+ return typeCmp;
862
+ }
863
+ const queryCmp = String(a.query || "").localeCompare(String(b.query || ""));
864
+ if (queryCmp !== 0) {
865
+ return queryCmp;
866
+ }
867
+ const statusCmp = Number(a.status || 0) - Number(b.status || 0);
868
+ if (statusCmp !== 0) {
869
+ return statusCmp;
870
+ }
871
+ return String(a.error || "").localeCompare(String(b.error || ""));
872
+ });
873
+ }
874
+
875
+ function normalizeGraphSourceIds(args = {}) {
876
+ const values = [];
877
+ if (args.id !== undefined && args.id !== null) {
878
+ values.push(args.id);
879
+ }
880
+ for (const id of ensureStringArray(args.ids, "ids") || []) {
881
+ values.push(id);
882
+ }
883
+ return uniqueStrings(values).sort(compareIdAsc);
884
+ }
885
+
886
+ function normalizeGraphSearchQueries(value) {
887
+ return uniqueStrings(ensureStringArray(value, "searchQueries") || []);
888
+ }
889
+
890
+ const DEFAULT_ISSUE_KEY_PATTERN = "[A-Z][A-Z0-9]+-\\d+";
891
+ const ISSUE_LINK_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
892
+ const ISSUE_LINK_TRAILING_PUNCTUATION = /[),.;!?]+$/;
893
+
894
+ function normalizeIssueDomains(value) {
895
+ const raw = ensureStringArray(value, "issueDomains") || [];
896
+ const out = [];
897
+ const seen = new Set();
898
+
899
+ for (const item of raw) {
900
+ const normalized = String(item || "")
901
+ .trim()
902
+ .toLowerCase()
903
+ .replace(/^\*\./, "")
904
+ .replace(/\.$/, "");
905
+ if (!normalized || seen.has(normalized)) {
906
+ continue;
907
+ }
908
+ seen.add(normalized);
909
+ out.push(normalized);
910
+ }
911
+
912
+ return out.sort(compareIdAsc);
913
+ }
914
+
915
+ function normalizeIssueKeyPatternSource(value) {
916
+ if (value === undefined || value === null || value === "") {
917
+ return DEFAULT_ISSUE_KEY_PATTERN;
918
+ }
919
+
920
+ const source = String(value).trim();
921
+ if (!source) {
922
+ return DEFAULT_ISSUE_KEY_PATTERN;
923
+ }
924
+ try {
925
+ // Validate custom pattern once and always run with global matching.
926
+ // eslint-disable-next-line no-new
927
+ new RegExp(source, "g");
928
+ } catch (err) {
929
+ throw new CliError(`Invalid keyPattern regex: ${err.message}`);
930
+ }
931
+ return source;
932
+ }
933
+
934
+ function collectIssueKeyMatches(text, keyPatternSource) {
935
+ const sourceText = String(text || "");
936
+ if (!sourceText) {
937
+ return [];
938
+ }
939
+
940
+ const out = [];
941
+ const regex = new RegExp(keyPatternSource, "g");
942
+ let match;
943
+ while ((match = regex.exec(sourceText)) !== null) {
944
+ const raw = String(match[0] || "").trim();
945
+ if (raw) {
946
+ out.push(raw.toUpperCase());
947
+ }
948
+ if (match[0] === "") {
949
+ regex.lastIndex += 1;
950
+ }
951
+ }
952
+ return out;
953
+ }
954
+
955
+ function collectIssueLinkMatches(text) {
956
+ const sourceText = String(text || "");
957
+ if (!sourceText) {
958
+ return [];
959
+ }
960
+
961
+ const out = [];
962
+ const regex = new RegExp(ISSUE_LINK_PATTERN.source, "gi");
963
+ let match;
964
+ while ((match = regex.exec(sourceText)) !== null) {
965
+ const matchedValue = String(match[0] || "");
966
+ if (!matchedValue) {
967
+ if (match[0] === "") {
968
+ regex.lastIndex += 1;
969
+ }
970
+ continue;
971
+ }
972
+
973
+ const sanitized = matchedValue.replace(ISSUE_LINK_TRAILING_PUNCTUATION, "");
974
+ if (!sanitized) {
975
+ continue;
976
+ }
977
+
978
+ out.push({
979
+ url: sanitized,
980
+ start: match.index,
981
+ end: match.index + sanitized.length,
982
+ });
983
+
984
+ if (match[0] === "") {
985
+ regex.lastIndex += 1;
986
+ }
987
+ }
988
+
989
+ return out;
990
+ }
991
+
992
+ function maskIssueLinkRanges(text, ranges = []) {
993
+ const sourceText = String(text || "");
994
+ if (!sourceText || ranges.length === 0) {
995
+ return sourceText;
996
+ }
997
+
998
+ const sorted = [...ranges].sort((a, b) => Number(a.start || 0) - Number(b.start || 0));
999
+ let cursor = 0;
1000
+ let output = "";
1001
+
1002
+ for (const range of sorted) {
1003
+ const start = Math.max(0, Math.min(sourceText.length, Number(range.start || 0)));
1004
+ const end = Math.max(start, Math.min(sourceText.length, Number(range.end || start)));
1005
+ if (start > cursor) {
1006
+ output += sourceText.slice(cursor, start);
1007
+ }
1008
+ if (end > start) {
1009
+ output += " ".repeat(end - start);
1010
+ }
1011
+ cursor = Math.max(cursor, end);
1012
+ }
1013
+
1014
+ if (cursor < sourceText.length) {
1015
+ output += sourceText.slice(cursor);
1016
+ }
1017
+ return output;
1018
+ }
1019
+
1020
+ function normalizeIssueUrl(raw) {
1021
+ try {
1022
+ const parsed = new URL(String(raw || ""));
1023
+ if (!/^https?:$/.test(parsed.protocol)) {
1024
+ return null;
1025
+ }
1026
+ return {
1027
+ url: parsed.toString(),
1028
+ domain: parsed.hostname.toLowerCase(),
1029
+ };
1030
+ } catch {
1031
+ return null;
1032
+ }
1033
+ }
1034
+
1035
+ function matchesIssueDomain(hostname, issueDomains = []) {
1036
+ if (!hostname) {
1037
+ return false;
1038
+ }
1039
+ if (!Array.isArray(issueDomains) || issueDomains.length === 0) {
1040
+ return true;
1041
+ }
1042
+ const host = String(hostname).toLowerCase();
1043
+ return issueDomains.some((domain) => host === domain || host.endsWith(`.${domain}`));
1044
+ }
1045
+
1046
+ function sortIssueRefs(rows = []) {
1047
+ return rows.sort((a, b) => {
1048
+ const keyCmp = compareIdAsc(a.key, b.key);
1049
+ if (keyCmp !== 0) {
1050
+ return keyCmp;
1051
+ }
1052
+ const domainCmp = compareIdAsc(a.domain, b.domain);
1053
+ if (domainCmp !== 0) {
1054
+ return domainCmp;
1055
+ }
1056
+ const urlCmp = compareIdAsc(a.url, b.url);
1057
+ if (urlCmp !== 0) {
1058
+ return urlCmp;
1059
+ }
1060
+ const sourcesCmp = String(a.sources?.join(",") || "").localeCompare(String(b.sources?.join(",") || ""));
1061
+ if (sourcesCmp !== 0) {
1062
+ return sourcesCmp;
1063
+ }
1064
+ return Number(a.count || 0) - Number(b.count || 0);
1065
+ });
1066
+ }
1067
+
1068
+ function extractIssueRefsFromText(text, options = {}) {
1069
+ const sourceText = String(text || "");
1070
+ const issueDomains = Array.isArray(options.issueDomains) ? options.issueDomains : [];
1071
+ const keyPatternSource = normalizeIssueKeyPatternSource(options.keyPattern);
1072
+ const linkMatches = collectIssueLinkMatches(sourceText);
1073
+ const maskedText = maskIssueLinkRanges(
1074
+ sourceText,
1075
+ linkMatches.map((item) => ({
1076
+ start: item.start,
1077
+ end: item.end,
1078
+ }))
1079
+ );
1080
+ const refMap = new Map();
1081
+
1082
+ const upsertRef = ({ key = "", url = "", domain = "", fromUrl = false, fromKeyPattern = false }) => {
1083
+ const normalizedKey = key ? String(key).trim().toUpperCase() : "";
1084
+ const normalizedUrl = url ? String(url).trim() : "";
1085
+ const normalizedDomain = domain ? String(domain).trim().toLowerCase() : "";
1086
+ const rowKey = [normalizedKey, normalizedDomain, normalizedUrl].join("\u0000");
1087
+
1088
+ const existing = refMap.get(rowKey);
1089
+ if (!existing) {
1090
+ refMap.set(rowKey, {
1091
+ key: normalizedKey,
1092
+ url: normalizedUrl,
1093
+ domain: normalizedDomain,
1094
+ fromUrl: fromUrl === true,
1095
+ fromKeyPattern: fromKeyPattern === true,
1096
+ count: 1,
1097
+ });
1098
+ return;
1099
+ }
1100
+
1101
+ existing.fromUrl = existing.fromUrl || fromUrl === true;
1102
+ existing.fromKeyPattern = existing.fromKeyPattern || fromKeyPattern === true;
1103
+ existing.count += 1;
1104
+ };
1105
+
1106
+ const standaloneKeys = collectIssueKeyMatches(maskedText, keyPatternSource);
1107
+ for (const key of standaloneKeys) {
1108
+ upsertRef({
1109
+ key,
1110
+ fromKeyPattern: true,
1111
+ });
1112
+ }
1113
+
1114
+ for (const match of linkMatches) {
1115
+ const normalized = normalizeIssueUrl(match.url);
1116
+ if (!normalized || !matchesIssueDomain(normalized.domain, issueDomains)) {
1117
+ continue;
1118
+ }
1119
+
1120
+ const linkedKeys = uniqueStrings(collectIssueKeyMatches(normalized.url, keyPatternSource));
1121
+ if (linkedKeys.length === 0) {
1122
+ upsertRef({
1123
+ url: normalized.url,
1124
+ domain: normalized.domain,
1125
+ fromUrl: true,
1126
+ });
1127
+ continue;
1128
+ }
1129
+
1130
+ for (const key of linkedKeys) {
1131
+ upsertRef({
1132
+ key,
1133
+ url: normalized.url,
1134
+ domain: normalized.domain,
1135
+ fromUrl: true,
1136
+ fromKeyPattern: true,
1137
+ });
1138
+ }
1139
+ }
1140
+
1141
+ const refs = sortIssueRefs(
1142
+ Array.from(refMap.values()).map((row) => ({
1143
+ key: row.key,
1144
+ url: row.url,
1145
+ domain: row.domain,
1146
+ sources: [
1147
+ ...(row.fromKeyPattern ? ["key_pattern"] : []),
1148
+ ...(row.fromUrl ? ["url"] : []),
1149
+ ],
1150
+ count: row.count,
1151
+ }))
1152
+ );
1153
+
1154
+ const keys = uniqueStrings(refs.map((row) => row.key).filter(Boolean)).sort(compareIdAsc);
1155
+ return {
1156
+ refs,
1157
+ keys,
1158
+ summary: {
1159
+ refCount: refs.length,
1160
+ urlRefCount: refs.filter((row) => row.sources.includes("url")).length,
1161
+ keyRefCount: refs.filter((row) => row.sources.includes("key_pattern")).length,
1162
+ keyCount: keys.length,
1163
+ mentionCount: refs.reduce((sum, row) => sum + Number(row.count || 0), 0),
1164
+ textLength: sourceText.length,
1165
+ },
1166
+ };
1167
+ }
1168
+
1169
+ function normalizeIssueDocumentView(view) {
1170
+ return normalizeGraphView(view);
1171
+ }
1172
+
1173
+ function normalizeIssueDocument(row, fallbackId, view = "summary") {
1174
+ const source = row && typeof row === "object" ? row : {};
1175
+ const merged = source.id ? source : { ...source, id: fallbackId };
1176
+ return normalizeGraphNode(merged, view);
1177
+ }
1178
+
1179
+ function extractDocumentTextForIssueRefs(row) {
1180
+ if (typeof row?.text === "string") {
1181
+ return row.text;
1182
+ }
1183
+ if (row?.data && typeof row.data === "object") {
1184
+ const textNodes = collectTemplateTextNodes(row.data);
1185
+ return textNodes.map((node) => node.text).join("\n");
1186
+ }
1187
+ return "";
1188
+ }
1189
+
1190
+ function sortIssueRefErrors(errors = []) {
1191
+ return errors.sort((a, b) => {
1192
+ const idCmp = compareIdAsc(a.id, b.id);
1193
+ if (idCmp !== 0) {
1194
+ return idCmp;
1195
+ }
1196
+ const statusCmp = Number(a.status || 0) - Number(b.status || 0);
1197
+ if (statusCmp !== 0) {
1198
+ return statusCmp;
1199
+ }
1200
+ return String(a.error || "").localeCompare(String(b.error || ""));
1201
+ });
1202
+ }
1203
+
1204
+ function normalizeIssueRefIds(args = {}) {
1205
+ const values = [];
1206
+ if (args.id !== undefined && args.id !== null) {
1207
+ values.push(args.id);
1208
+ }
1209
+ for (const id of ensureStringArray(args.ids, "ids") || []) {
1210
+ values.push(id);
1211
+ }
1212
+ return uniqueStrings(values).sort(compareIdAsc);
1213
+ }
1214
+
1215
+ async function collectIssueRefsByIds(ctx, ids, options = {}) {
1216
+ const documentIds = uniqueStrings(ids).sort(compareIdAsc);
1217
+ const maxAttempts = Math.max(1, toInteger(options.maxAttempts, 2));
1218
+ const view = normalizeIssueDocumentView(options.view);
1219
+ const issueDomains = normalizeIssueDomains(options.issueDomains);
1220
+ const keyPattern = normalizeIssueKeyPatternSource(options.keyPattern);
1221
+ const concurrency = Math.min(4, Math.max(1, documentIds.length || 1));
1222
+
1223
+ const items = await mapLimit(documentIds, concurrency, async (id) => {
1224
+ try {
1225
+ const infoRes = await ctx.client.call("documents.info", { id }, { maxAttempts });
1226
+ const row = infoRes.body?.data || { id };
1227
+ const text = extractDocumentTextForIssueRefs(row);
1228
+ const extraction = extractIssueRefsFromText(text, {
1229
+ issueDomains,
1230
+ keyPattern,
1231
+ });
1232
+ return {
1233
+ id,
1234
+ ok: true,
1235
+ document: normalizeIssueDocument(row, id, view),
1236
+ refs: extraction.refs,
1237
+ keys: extraction.keys,
1238
+ summary: extraction.summary,
1239
+ };
1240
+ } catch (err) {
1241
+ if (err instanceof ApiError) {
1242
+ return {
1243
+ id,
1244
+ ok: false,
1245
+ error: err.message,
1246
+ status: err.details.status,
1247
+ };
1248
+ }
1249
+ throw err;
1250
+ }
1251
+ });
1252
+
1253
+ const documents = items
1254
+ .filter((item) => item.ok)
1255
+ .sort((a, b) => compareIdAsc(a.id, b.id))
1256
+ .map((item) => ({
1257
+ document: item.document,
1258
+ summary: item.summary,
1259
+ keys: item.keys,
1260
+ refs: item.refs,
1261
+ }));
1262
+ const errors = sortIssueRefErrors(
1263
+ items
1264
+ .filter((item) => !item.ok)
1265
+ .map((item) => ({
1266
+ id: item.id,
1267
+ error: item.error,
1268
+ status: item.status,
1269
+ }))
1270
+ );
1271
+ const allKeys = uniqueStrings(
1272
+ documents.flatMap((item) => item.keys || []).filter(Boolean)
1273
+ ).sort(compareIdAsc);
1274
+ const totalMentions = documents.reduce(
1275
+ (sum, item) => sum + Number(item.summary?.mentionCount || 0),
1276
+ 0
1277
+ );
1278
+ const totalRefCount = documents.reduce((sum, item) => sum + Number(item.summary?.refCount || 0), 0);
1279
+ const documentsWithRefs = documents.filter((item) => Number(item.summary?.refCount || 0) > 0).length;
1280
+
1281
+ return {
1282
+ issueDomains,
1283
+ keyPattern,
1284
+ requestedIds: documentIds,
1285
+ documents,
1286
+ errors,
1287
+ summary: {
1288
+ documentCount: documents.length,
1289
+ documentsWithRefs,
1290
+ refCount: totalRefCount,
1291
+ keyCount: allKeys.length,
1292
+ mentionCount: totalMentions,
1293
+ },
1294
+ keys: allKeys,
1295
+ };
1296
+ }
1297
+
1298
+ async function resolveIssueReportCandidates(ctx, args = {}) {
1299
+ const queries = normalizeProbeQueries(args);
1300
+ if (queries.length === 0) {
1301
+ throw new CliError("documents.issue_ref_report requires args.query or args.queries[]");
1302
+ }
1303
+
1304
+ const limit = Math.max(1, Math.min(100, toInteger(args.limit, 10)));
1305
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
1306
+ const collectionId = args.collectionId ? String(args.collectionId) : "";
1307
+ const queryConcurrency = Math.min(3, Math.max(1, queries.length));
1308
+
1309
+ const perQuery = await mapLimit(queries, queryConcurrency, async (query) => {
1310
+ const hits = [];
1311
+ const errors = [];
1312
+ const baseBody = compactValue({
1313
+ query,
1314
+ collectionId: collectionId || undefined,
1315
+ limit,
1316
+ offset: 0,
1317
+ }) || {};
1318
+
1319
+ try {
1320
+ const titlesRes = await ctx.client.call("documents.search_titles", baseBody, { maxAttempts });
1321
+ const rows = Array.isArray(titlesRes.body?.data) ? titlesRes.body.data : [];
1322
+ for (let i = 0; i < rows.length; i += 1) {
1323
+ const normalized = normalizeProbeTitleHit(rows[i], i);
1324
+ if (normalized) {
1325
+ hits.push(normalized);
1326
+ }
1327
+ }
1328
+ } catch (err) {
1329
+ if (err instanceof ApiError) {
1330
+ errors.push({
1331
+ source: "titles",
1332
+ error: err.message,
1333
+ status: err.details.status,
1334
+ });
1335
+ } else {
1336
+ throw err;
1337
+ }
1338
+ }
1339
+
1340
+ try {
1341
+ const semanticRes = await ctx.client.call(
1342
+ "documents.search",
1343
+ {
1344
+ ...baseBody,
1345
+ snippetMinWords: 16,
1346
+ snippetMaxWords: 24,
1347
+ },
1348
+ { maxAttempts }
1349
+ );
1350
+ const rows = Array.isArray(semanticRes.body?.data) ? semanticRes.body.data : [];
1351
+ for (let i = 0; i < rows.length; i += 1) {
1352
+ const normalized = normalizeProbeSemanticHit(rows[i], i);
1353
+ if (normalized) {
1354
+ hits.push(normalized);
1355
+ }
1356
+ }
1357
+ } catch (err) {
1358
+ if (err instanceof ApiError) {
1359
+ errors.push({
1360
+ source: "semantic",
1361
+ error: err.message,
1362
+ status: err.details.status,
1363
+ });
1364
+ } else {
1365
+ throw err;
1366
+ }
1367
+ }
1368
+
1369
+ const rankedHits = mergeProbeHits(hits, limit);
1370
+ return {
1371
+ query,
1372
+ hitCount: rankedHits.length,
1373
+ hits: rankedHits,
1374
+ errors,
1375
+ };
1376
+ });
1377
+
1378
+ const candidateMap = new Map();
1379
+ for (const [queryIndex, queryResult] of perQuery.entries()) {
1380
+ for (const hit of queryResult.hits) {
1381
+ const existing = candidateMap.get(hit.id);
1382
+ if (!existing) {
1383
+ candidateMap.set(hit.id, {
1384
+ id: hit.id,
1385
+ title: hit.title,
1386
+ collectionId: hit.collectionId,
1387
+ updatedAt: hit.updatedAt,
1388
+ publishedAt: hit.publishedAt,
1389
+ urlId: hit.urlId,
1390
+ ranking: hit.ranking,
1391
+ sources: [...(Array.isArray(hit.sources) ? hit.sources : [])].sort((a, b) => a.localeCompare(b)),
1392
+ queries: [queryResult.query],
1393
+ bestRank: hit.rank,
1394
+ firstQueryIndex: queryIndex,
1395
+ });
1396
+ continue;
1397
+ }
1398
+
1399
+ existing.ranking = Math.max(existing.ranking, hit.ranking);
1400
+ if (compareIsoDesc(existing.updatedAt, hit.updatedAt) > 0) {
1401
+ existing.updatedAt = hit.updatedAt;
1402
+ existing.publishedAt = hit.publishedAt;
1403
+ }
1404
+ if (!existing.queries.includes(queryResult.query)) {
1405
+ existing.queries.push(queryResult.query);
1406
+ existing.queries.sort((a, b) => a.localeCompare(b));
1407
+ }
1408
+ for (const source of hit.sources || []) {
1409
+ if (!existing.sources.includes(source)) {
1410
+ existing.sources.push(source);
1411
+ }
1412
+ }
1413
+ existing.sources.sort((a, b) => a.localeCompare(b));
1414
+ existing.bestRank = Math.min(existing.bestRank, hit.rank);
1415
+ existing.firstQueryIndex = Math.min(existing.firstQueryIndex, queryIndex);
1416
+ }
1417
+ }
1418
+
1419
+ const candidates = Array.from(candidateMap.values())
1420
+ .sort((a, b) => {
1421
+ if (b.ranking !== a.ranking) {
1422
+ return b.ranking - a.ranking;
1423
+ }
1424
+ const updatedCmp = compareIsoDesc(a.updatedAt, b.updatedAt);
1425
+ if (updatedCmp !== 0) {
1426
+ return updatedCmp;
1427
+ }
1428
+ if (a.firstQueryIndex !== b.firstQueryIndex) {
1429
+ return a.firstQueryIndex - b.firstQueryIndex;
1430
+ }
1431
+ if (a.bestRank !== b.bestRank) {
1432
+ return a.bestRank - b.bestRank;
1433
+ }
1434
+ return compareIdAsc(a.id, b.id);
1435
+ })
1436
+ .slice(0, limit)
1437
+ .map((candidate, index) => ({
1438
+ rank: index + 1,
1439
+ id: candidate.id,
1440
+ title: candidate.title,
1441
+ collectionId: candidate.collectionId,
1442
+ updatedAt: candidate.updatedAt,
1443
+ publishedAt: candidate.publishedAt,
1444
+ urlId: candidate.urlId,
1445
+ ranking: candidate.ranking,
1446
+ sources: candidate.sources,
1447
+ queries: candidate.queries,
1448
+ }));
1449
+
1450
+ return {
1451
+ queries,
1452
+ collectionId,
1453
+ limit,
1454
+ perQuery,
1455
+ candidates,
1456
+ candidateIds: candidates.map((item) => item.id),
1457
+ };
1458
+ }
1459
+
1460
+ async function fetchGraphSourceDocs(ctx, sourceIds, maxAttempts) {
1461
+ const ids = uniqueStrings(sourceIds).sort(compareIdAsc);
1462
+ const byId = new Map();
1463
+ const errors = [];
1464
+
1465
+ const items = await mapLimit(ids, Math.min(4, Math.max(1, ids.length || 1)), async (id) => {
1466
+ try {
1467
+ const res = await ctx.client.call("documents.info", { id }, { maxAttempts });
1468
+ return {
1469
+ id,
1470
+ row: res.body?.data || { id },
1471
+ };
1472
+ } catch (err) {
1473
+ if (err instanceof ApiError) {
1474
+ return {
1475
+ id,
1476
+ row: { id },
1477
+ error: err.message,
1478
+ status: err.details.status,
1479
+ };
1480
+ }
1481
+ throw err;
1482
+ }
1483
+ });
1484
+
1485
+ for (const item of items) {
1486
+ byId.set(item.id, item.row);
1487
+ if (item.error) {
1488
+ errors.push({
1489
+ sourceId: item.id,
1490
+ type: "source_info",
1491
+ query: "",
1492
+ status: item.status,
1493
+ error: item.error,
1494
+ });
1495
+ }
1496
+ }
1497
+
1498
+ return {
1499
+ byId,
1500
+ errors: sortGraphErrors(errors),
1501
+ };
1502
+ }
1503
+
1504
+ function buildGraphNodeList(nodesById, view = "summary") {
1505
+ return Array.from(nodesById.entries())
1506
+ .sort(([a], [b]) => compareIdAsc(a, b))
1507
+ .map(([id, row]) => {
1508
+ const normalized = normalizeGraphNode(row || { id }, view);
1509
+ if (normalized) {
1510
+ return normalized;
1511
+ }
1512
+ return normalizeGraphNode({ id }, view);
1513
+ })
1514
+ .filter(Boolean);
1515
+ }
1516
+
1517
+ async function collectGraphNeighbors(ctx, sourceIds, options = {}) {
1518
+ const sortedSourceIds = uniqueStrings(sourceIds).sort(compareIdAsc);
1519
+ const includeBacklinks = options.includeBacklinks !== false;
1520
+ const includeSearchNeighbors = options.includeSearchNeighbors === true;
1521
+ const limitPerSource = Math.max(1, Math.min(100, toInteger(options.limitPerSource, 10)));
1522
+ const maxAttempts = Math.max(1, toInteger(options.maxAttempts, 2));
1523
+ const explicitSearchQueries = normalizeGraphSearchQueries(options.searchQueries);
1524
+ const hydrateSources =
1525
+ options.hydrateSources === true ||
1526
+ includeSearchNeighbors ||
1527
+ options.view === "summary" ||
1528
+ options.view === "full";
1529
+
1530
+ const sourceDocsById =
1531
+ options.sourceDocsById instanceof Map ? new Map(options.sourceDocsById) : new Map();
1532
+ const nodesById = new Map();
1533
+ const edgeMap = new Map();
1534
+ const errors = [];
1535
+
1536
+ if (hydrateSources) {
1537
+ const missing = sortedSourceIds.filter((id) => !sourceDocsById.has(id));
1538
+ if (missing.length > 0) {
1539
+ const hydrated = await fetchGraphSourceDocs(ctx, missing, maxAttempts);
1540
+ for (const [id, row] of hydrated.byId.entries()) {
1541
+ sourceDocsById.set(id, row);
1542
+ }
1543
+ errors.push(...hydrated.errors);
1544
+ }
1545
+ }
1546
+
1547
+ for (const sourceId of sortedSourceIds) {
1548
+ const sourceNode = sourceDocsById.get(sourceId) || { id: sourceId };
1549
+ upsertGraphNode(nodesById, sourceNode);
1550
+
1551
+ if (includeBacklinks) {
1552
+ try {
1553
+ const backlinksRes = await ctx.client.call(
1554
+ "documents.list",
1555
+ {
1556
+ backlinkDocumentId: sourceId,
1557
+ limit: limitPerSource,
1558
+ offset: 0,
1559
+ sort: "updatedAt",
1560
+ direction: "DESC",
1561
+ },
1562
+ { maxAttempts }
1563
+ );
1564
+ const rows = Array.isArray(backlinksRes.body?.data) ? backlinksRes.body.data : [];
1565
+
1566
+ for (let i = 0; i < rows.length; i += 1) {
1567
+ const row = rows[i];
1568
+ const targetId = row?.id ? String(row.id) : "";
1569
+ if (!targetId || targetId === sourceId) {
1570
+ continue;
1571
+ }
1572
+
1573
+ upsertGraphNode(nodesById, row);
1574
+ const key = `${sourceId}\u0000${targetId}\u0000backlink`;
1575
+ const edge = {
1576
+ sourceId,
1577
+ targetId,
1578
+ type: "backlink",
1579
+ query: "",
1580
+ rank: i + 1,
1581
+ };
1582
+ const existing = edgeMap.get(key);
1583
+ if (!existing || edge.rank < existing.rank) {
1584
+ edgeMap.set(key, edge);
1585
+ }
1586
+ }
1587
+ } catch (err) {
1588
+ if (err instanceof ApiError) {
1589
+ errors.push({
1590
+ sourceId,
1591
+ type: "backlink",
1592
+ query: "",
1593
+ status: err.details.status,
1594
+ error: err.message,
1595
+ });
1596
+ } else {
1597
+ throw err;
1598
+ }
1599
+ }
1600
+ }
1601
+
1602
+ if (includeSearchNeighbors) {
1603
+ let queries = explicitSearchQueries;
1604
+ if (queries.length === 0) {
1605
+ const inferred = String(sourceNode?.title || "").trim();
1606
+ queries = inferred ? [inferred] : [];
1607
+ }
1608
+ if (queries.length === 0) {
1609
+ continue;
1610
+ }
1611
+
1612
+ const searchCandidates = new Map();
1613
+ for (const query of queries) {
1614
+ try {
1615
+ const searchRes = await ctx.client.call(
1616
+ "documents.search_titles",
1617
+ {
1618
+ query,
1619
+ limit: limitPerSource,
1620
+ offset: 0,
1621
+ },
1622
+ { maxAttempts }
1623
+ );
1624
+ const rows = Array.isArray(searchRes.body?.data) ? searchRes.body.data : [];
1625
+
1626
+ for (let i = 0; i < rows.length; i += 1) {
1627
+ const row = rows[i];
1628
+ const targetId = row?.id ? String(row.id) : "";
1629
+ if (!targetId || targetId === sourceId) {
1630
+ continue;
1631
+ }
1632
+ upsertGraphNode(nodesById, row);
1633
+
1634
+ const ranking = normalizeProbeRanking(row?.ranking, i);
1635
+ const updatedAt = row?.updatedAt ? String(row.updatedAt) : "";
1636
+ const existing = searchCandidates.get(targetId);
1637
+ if (!existing) {
1638
+ searchCandidates.set(targetId, {
1639
+ targetId,
1640
+ ranking,
1641
+ query,
1642
+ updatedAt,
1643
+ });
1644
+ continue;
1645
+ }
1646
+
1647
+ if (ranking > existing.ranking) {
1648
+ searchCandidates.set(targetId, {
1649
+ targetId,
1650
+ ranking,
1651
+ query,
1652
+ updatedAt,
1653
+ });
1654
+ continue;
1655
+ }
1656
+
1657
+ if (
1658
+ ranking === existing.ranking &&
1659
+ compareIsoDesc(existing.updatedAt, updatedAt) > 0
1660
+ ) {
1661
+ searchCandidates.set(targetId, {
1662
+ targetId,
1663
+ ranking,
1664
+ query,
1665
+ updatedAt,
1666
+ });
1667
+ }
1668
+ }
1669
+ } catch (err) {
1670
+ if (err instanceof ApiError) {
1671
+ errors.push({
1672
+ sourceId,
1673
+ type: "search",
1674
+ query,
1675
+ status: err.details.status,
1676
+ error: err.message,
1677
+ });
1678
+ } else {
1679
+ throw err;
1680
+ }
1681
+ }
1682
+ }
1683
+
1684
+ const ranked = Array.from(searchCandidates.values())
1685
+ .sort((a, b) => {
1686
+ if (b.ranking !== a.ranking) {
1687
+ return b.ranking - a.ranking;
1688
+ }
1689
+ const updatedCmp = compareIsoDesc(a.updatedAt, b.updatedAt);
1690
+ if (updatedCmp !== 0) {
1691
+ return updatedCmp;
1692
+ }
1693
+ return compareIdAsc(a.targetId, b.targetId);
1694
+ })
1695
+ .slice(0, limitPerSource);
1696
+
1697
+ for (let i = 0; i < ranked.length; i += 1) {
1698
+ const item = ranked[i];
1699
+ const key = `${sourceId}\u0000${item.targetId}\u0000search`;
1700
+ const edge = {
1701
+ sourceId,
1702
+ targetId: item.targetId,
1703
+ type: "search",
1704
+ query: item.query,
1705
+ rank: i + 1,
1706
+ };
1707
+ const existing = edgeMap.get(key);
1708
+ if (
1709
+ !existing ||
1710
+ edge.rank < existing.rank ||
1711
+ (edge.rank === existing.rank && edge.query.localeCompare(existing.query) < 0)
1712
+ ) {
1713
+ edgeMap.set(key, edge);
1714
+ }
1715
+ }
1716
+ }
1717
+ }
1718
+
1719
+ return {
1720
+ nodesById,
1721
+ edges: sortGraphEdges(Array.from(edgeMap.values())),
1722
+ errors: sortGraphErrors(errors),
1723
+ sourceDocsById,
1724
+ };
1725
+ }
1726
+
1727
+ async function documentsBacklinksTool(ctx, args = {}) {
1728
+ const id = String(args.id || "").trim();
1729
+ if (!id) {
1730
+ throw new CliError("documents.backlinks requires args.id");
1731
+ }
1732
+
1733
+ const view = normalizeGraphView(args.view);
1734
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
1735
+
1736
+ const res = await ctx.client.call(
1737
+ "documents.list",
1738
+ compactValue({
1739
+ backlinkDocumentId: id,
1740
+ limit: toInteger(args.limit, 25),
1741
+ offset: toInteger(args.offset, 0),
1742
+ sort: args.sort,
1743
+ direction: args.direction,
1744
+ }) || {},
1745
+ { maxAttempts }
1746
+ );
1747
+
1748
+ let payload = res.body;
1749
+ if (view !== "full" && payload && typeof payload === "object") {
1750
+ payload = {
1751
+ ...payload,
1752
+ data: Array.isArray(payload.data) ? payload.data.map((row) => normalizeGraphNode(row, view)) : [],
1753
+ };
1754
+ }
1755
+ payload = maybeDropPolicies(payload, !!args.includePolicies);
1756
+
1757
+ return {
1758
+ tool: "documents.backlinks",
1759
+ profile: ctx.profile.id,
1760
+ result: payload,
1761
+ };
1762
+ }
1763
+
1764
+ async function documentsGraphNeighborsTool(ctx, args = {}) {
1765
+ const sourceIds = normalizeGraphSourceIds(args);
1766
+ if (sourceIds.length === 0) {
1767
+ throw new CliError("documents.graph_neighbors requires args.id or args.ids[]");
1768
+ }
1769
+
1770
+ const includeBacklinks = args.includeBacklinks !== false;
1771
+ const includeSearchNeighbors = args.includeSearchNeighbors === true;
1772
+ if (!includeBacklinks && !includeSearchNeighbors) {
1773
+ throw new CliError("documents.graph_neighbors requires includeBacklinks or includeSearchNeighbors");
1774
+ }
1775
+
1776
+ const view = normalizeGraphView(args.view);
1777
+ const limitPerSource = Math.max(1, Math.min(100, toInteger(args.limitPerSource, 10)));
1778
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
1779
+ const searchQueries = normalizeGraphSearchQueries(args.searchQueries);
1780
+
1781
+ const collected = await collectGraphNeighbors(ctx, sourceIds, {
1782
+ includeBacklinks,
1783
+ includeSearchNeighbors,
1784
+ searchQueries,
1785
+ limitPerSource,
1786
+ maxAttempts,
1787
+ hydrateSources: view !== "ids" || includeSearchNeighbors,
1788
+ view,
1789
+ });
1790
+
1791
+ const nodes = buildGraphNodeList(collected.nodesById, view);
1792
+ const edges = sortGraphEdges(collected.edges);
1793
+
1794
+ return {
1795
+ tool: "documents.graph_neighbors",
1796
+ profile: ctx.profile.id,
1797
+ result: {
1798
+ sourceIds,
1799
+ includeBacklinks,
1800
+ includeSearchNeighbors,
1801
+ searchQueries,
1802
+ limitPerSource,
1803
+ nodeCount: nodes.length,
1804
+ edgeCount: edges.length,
1805
+ nodes,
1806
+ edges,
1807
+ errors: collected.errors,
1808
+ },
1809
+ };
1810
+ }
1811
+
1812
+ async function documentsGraphReportTool(ctx, args = {}) {
1813
+ const requestedSeedIds = uniqueStrings(ensureStringArray(args.seedIds, "seedIds") || []).sort(compareIdAsc);
1814
+ if (requestedSeedIds.length === 0) {
1815
+ throw new CliError("documents.graph_report requires args.seedIds[]");
1816
+ }
1817
+
1818
+ const includeBacklinks = args.includeBacklinks !== false;
1819
+ const includeSearchNeighbors = args.includeSearchNeighbors === true;
1820
+ if (!includeBacklinks && !includeSearchNeighbors) {
1821
+ throw new CliError("documents.graph_report requires includeBacklinks or includeSearchNeighbors");
1822
+ }
1823
+
1824
+ const depth = Math.max(0, Math.min(6, toInteger(args.depth, 2)));
1825
+ const maxNodes = Math.max(1, Math.min(500, toInteger(args.maxNodes, 120)));
1826
+ const limitPerSource = Math.max(1, Math.min(100, toInteger(args.limitPerSource, 10)));
1827
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
1828
+ const view = normalizeGraphView(args.view);
1829
+
1830
+ const seedIds = requestedSeedIds.slice(0, maxNodes);
1831
+ const visited = new Set(seedIds);
1832
+ const nodesById = new Map(seedIds.map((id) => [id, { id }]));
1833
+ const edgeMap = new Map();
1834
+ const errors = [];
1835
+
1836
+ let sourceDocsById = new Map();
1837
+ let frontier = [...seedIds];
1838
+ let exploredDepth = 0;
1839
+ let truncated = seedIds.length < requestedSeedIds.length;
1840
+
1841
+ for (let level = 0; level < depth && frontier.length > 0; level += 1) {
1842
+ const hop = await collectGraphNeighbors(ctx, frontier, {
1843
+ includeBacklinks,
1844
+ includeSearchNeighbors,
1845
+ searchQueries: [],
1846
+ limitPerSource,
1847
+ maxAttempts,
1848
+ sourceDocsById,
1849
+ hydrateSources: view !== "ids" || includeSearchNeighbors,
1850
+ view,
1851
+ });
1852
+ sourceDocsById = hop.sourceDocsById;
1853
+
1854
+ for (const [id, row] of hop.nodesById.entries()) {
1855
+ if (visited.has(id)) {
1856
+ upsertGraphNode(nodesById, row);
1857
+ }
1858
+ }
1859
+
1860
+ const next = new Set();
1861
+ for (const edge of hop.edges) {
1862
+ if (!visited.has(edge.sourceId)) {
1863
+ continue;
1864
+ }
1865
+
1866
+ if (!visited.has(edge.targetId)) {
1867
+ if (visited.size >= maxNodes) {
1868
+ truncated = true;
1869
+ continue;
1870
+ }
1871
+ visited.add(edge.targetId);
1872
+ next.add(edge.targetId);
1873
+ upsertGraphNode(nodesById, hop.nodesById.get(edge.targetId) || { id: edge.targetId });
1874
+ }
1875
+
1876
+ const key = `${edge.sourceId}\u0000${edge.targetId}\u0000${edge.type}`;
1877
+ const existing = edgeMap.get(key);
1878
+ if (
1879
+ !existing ||
1880
+ edge.rank < existing.rank ||
1881
+ (edge.rank === existing.rank && String(edge.query || "").localeCompare(String(existing.query || "")) < 0)
1882
+ ) {
1883
+ edgeMap.set(key, edge);
1884
+ }
1885
+ }
1886
+
1887
+ for (const error of hop.errors) {
1888
+ errors.push({
1889
+ ...error,
1890
+ hop: level + 1,
1891
+ });
1892
+ }
1893
+
1894
+ exploredDepth = level + 1;
1895
+ frontier = Array.from(next).sort(compareIdAsc);
1896
+ }
1897
+
1898
+ for (const id of visited) {
1899
+ if (!nodesById.has(id)) {
1900
+ upsertGraphNode(nodesById, sourceDocsById.get(id) || { id });
1901
+ }
1902
+ }
1903
+
1904
+ const allowedIds = new Set(visited);
1905
+ const filteredNodes = new Map(
1906
+ Array.from(nodesById.entries()).filter(([id]) => allowedIds.has(id))
1907
+ );
1908
+ const nodes = buildGraphNodeList(filteredNodes, view);
1909
+ const edges = sortGraphEdges(
1910
+ Array.from(edgeMap.values()).filter(
1911
+ (edge) => allowedIds.has(edge.sourceId) && allowedIds.has(edge.targetId)
1912
+ )
1913
+ );
1914
+
1915
+ return {
1916
+ tool: "documents.graph_report",
1917
+ profile: ctx.profile.id,
1918
+ result: {
1919
+ seedIds,
1920
+ requestedSeedCount: requestedSeedIds.length,
1921
+ depth,
1922
+ exploredDepth,
1923
+ maxNodes,
1924
+ includeBacklinks,
1925
+ includeSearchNeighbors,
1926
+ limitPerSource,
1927
+ truncated,
1928
+ nodeCount: nodes.length,
1929
+ edgeCount: edges.length,
1930
+ nodes,
1931
+ edges,
1932
+ errors: sortGraphErrors(errors),
1933
+ },
1934
+ };
1935
+ }
1936
+
1937
+ async function documentsIssueRefsTool(ctx, args = {}) {
1938
+ const ids = normalizeIssueRefIds(args);
1939
+ if (ids.length === 0) {
1940
+ throw new CliError("documents.issue_refs requires args.id or args.ids[]");
1941
+ }
1942
+
1943
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
1944
+ const view = normalizeIssueDocumentView(args.view);
1945
+
1946
+ const extracted = await collectIssueRefsByIds(ctx, ids, {
1947
+ issueDomains: args.issueDomains,
1948
+ keyPattern: args.keyPattern,
1949
+ maxAttempts,
1950
+ view,
1951
+ });
1952
+
1953
+ return {
1954
+ tool: "documents.issue_refs",
1955
+ profile: ctx.profile.id,
1956
+ result: {
1957
+ requestedIds: extracted.requestedIds,
1958
+ issueDomains: extracted.issueDomains,
1959
+ keyPattern: extracted.keyPattern,
1960
+ ...extracted.summary,
1961
+ keys: extracted.keys,
1962
+ documents: extracted.documents,
1963
+ errors: extracted.errors,
1964
+ },
1965
+ };
1966
+ }
1967
+
1968
+ async function documentsIssueRefReportTool(ctx, args = {}) {
1969
+ const resolved = await resolveIssueReportCandidates(ctx, args);
1970
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
1971
+ const view = normalizeIssueDocumentView(args.view);
1972
+ const extracted = await collectIssueRefsByIds(ctx, resolved.candidateIds, {
1973
+ issueDomains: args.issueDomains,
1974
+ keyPattern: args.keyPattern,
1975
+ maxAttempts,
1976
+ view,
1977
+ });
1978
+
1979
+ return {
1980
+ tool: "documents.issue_ref_report",
1981
+ profile: ctx.profile.id,
1982
+ result: {
1983
+ queries: resolved.queries,
1984
+ collectionId: resolved.collectionId,
1985
+ limit: resolved.limit,
1986
+ candidateCount: resolved.candidates.length,
1987
+ candidates: resolved.candidates,
1988
+ perQuery: resolved.perQuery,
1989
+ issueDomains: extracted.issueDomains,
1990
+ keyPattern: extracted.keyPattern,
1991
+ ...extracted.summary,
1992
+ keys: extracted.keys,
1993
+ documents: extracted.documents,
1994
+ errors: extracted.errors,
1995
+ },
1996
+ };
1997
+ }
1998
+
1999
+ async function templatesExtractPlaceholdersTool(ctx, args = {}) {
2000
+ const id = String(args.id || "").trim();
2001
+ if (!id) {
2002
+ throw new CliError("templates.extract_placeholders requires args.id");
2003
+ }
2004
+
2005
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
2006
+ const templateRes = await ctx.client.call("templates.info", { id }, { maxAttempts });
2007
+ const template = templateRes.body?.data || {};
2008
+ const textNodes = collectTemplateTextNodes(template?.data || {});
2009
+ const stats = collectPlaceholderStatsFromTexts(textNodes.map((node) => node.text));
2010
+
2011
+ return {
2012
+ tool: "templates.extract_placeholders",
2013
+ profile: ctx.profile.id,
2014
+ result: {
2015
+ id: template?.id ? String(template.id) : id,
2016
+ title: template?.title ? String(template.title) : "",
2017
+ placeholders: stats.placeholders,
2018
+ counts: stats.countsByPlaceholder,
2019
+ meta: {
2020
+ placeholderTokenCount: stats.tokenCount,
2021
+ uniquePlaceholderCount: stats.uniquePlaceholderCount,
2022
+ textNodeCount: stats.textNodeCount,
2023
+ scannedCharacterCount: stats.scannedCharacterCount,
2024
+ },
2025
+ },
2026
+ };
2027
+ }
2028
+
2029
+ async function documentsCreateFromTemplateTool(ctx, args = {}) {
2030
+ const templateId = String(args.templateId || "").trim();
2031
+ if (!templateId) {
2032
+ throw new CliError("documents.create_from_template requires args.templateId");
2033
+ }
2034
+
2035
+ assertPerformAction(args, {
2036
+ tool: "documents.create_from_template",
2037
+ action: "create and optionally update a document from template",
2038
+ });
2039
+
2040
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 1));
2041
+ const view = normalizeTemplatePipelineView(args.view);
2042
+ const strictPlaceholders = args.strictPlaceholders === true;
2043
+ const publishRequested = args.publish === true;
2044
+ const placeholderValues = normalizePlaceholderValues(args.placeholderValues);
2045
+ const providedPlaceholderKeys = Object.keys(placeholderValues).sort(compareIdAsc);
2046
+ const requiresPlaceholderPass = strictPlaceholders || providedPlaceholderKeys.length > 0;
2047
+ const createBody = compactValue({
2048
+ templateId,
2049
+ title: args.title,
2050
+ collectionId: args.collectionId,
2051
+ parentDocumentId: args.parentDocumentId,
2052
+ publish: requiresPlaceholderPass ? false : publishRequested,
2053
+ }) || {};
2054
+
2055
+ const createRes = await ctx.client.call("documents.create", createBody, { maxAttempts });
2056
+ const createPayload = maybeDropPolicies(createRes.body, !!args.includePolicies);
2057
+ let finalPayload = createPayload;
2058
+ const createdDoc = createRes.body?.data || {};
2059
+ const documentId = createdDoc?.id ? String(createdDoc.id) : "";
2060
+ if (!documentId) {
2061
+ throw new CliError("documents.create_from_template could not resolve created document id");
2062
+ }
2063
+
2064
+ if (!requiresPlaceholderPass) {
2065
+ return {
2066
+ tool: "documents.create_from_template",
2067
+ profile: ctx.profile.id,
2068
+ result: {
2069
+ success: true,
2070
+ strictPlaceholders,
2071
+ publishRequested,
2072
+ published: publishRequested,
2073
+ document: normalizeTemplatePipelineDocument(finalPayload?.data || createdDoc, view),
2074
+ placeholders: {
2075
+ providedKeys: [],
2076
+ unresolved: [],
2077
+ unresolvedCount: 0,
2078
+ totalBefore: 0,
2079
+ totalAfter: 0,
2080
+ replacedByPlaceholder: [],
2081
+ },
2082
+ actions: {
2083
+ create: true,
2084
+ updateText: false,
2085
+ publish: false,
2086
+ },
2087
+ },
2088
+ };
2089
+ }
2090
+
2091
+ let workingDoc = createdDoc;
2092
+ if (typeof workingDoc.text !== "string") {
2093
+ const infoRes = await ctx.client.call("documents.info", { id: documentId }, { maxAttempts });
2094
+ workingDoc = infoRes.body?.data || workingDoc;
2095
+ }
2096
+
2097
+ const sourceText = String(workingDoc?.text ?? "");
2098
+ const before = collectPlaceholderStatsFromTexts([sourceText]);
2099
+ const replaced = replacePlaceholdersInText(sourceText, placeholderValues);
2100
+ const after = collectPlaceholderStatsFromTexts([replaced.text]);
2101
+ const unresolved = [...after.placeholders];
2102
+ const hasUnresolved = unresolved.length > 0;
2103
+
2104
+ let updatedDoc = workingDoc;
2105
+ let textUpdated = false;
2106
+ if (replaced.text !== sourceText) {
2107
+ const updateTextRes = await ctx.client.call(
2108
+ "documents.update",
2109
+ {
2110
+ id: documentId,
2111
+ text: replaced.text,
2112
+ publish: false,
2113
+ },
2114
+ { maxAttempts }
2115
+ );
2116
+ finalPayload = maybeDropPolicies(updateTextRes.body, !!args.includePolicies);
2117
+ updatedDoc = updateTextRes.body?.data || updatedDoc;
2118
+ textUpdated = true;
2119
+ }
2120
+
2121
+ if (strictPlaceholders && hasUnresolved) {
2122
+ return {
2123
+ tool: "documents.create_from_template",
2124
+ profile: ctx.profile.id,
2125
+ result: {
2126
+ success: false,
2127
+ code: "STRICT_PLACEHOLDERS_UNRESOLVED",
2128
+ message: "strictPlaceholders=true and unresolved placeholders remain; document left unpublished",
2129
+ strictPlaceholders: true,
2130
+ publishRequested,
2131
+ published: false,
2132
+ safeBehavior: "left_unpublished_draft",
2133
+ document: normalizeTemplatePipelineDocument(updatedDoc, view),
2134
+ placeholders: {
2135
+ providedKeys: providedPlaceholderKeys,
2136
+ unresolved,
2137
+ unresolvedCount: unresolved.length,
2138
+ totalBefore: before.tokenCount,
2139
+ totalAfter: after.tokenCount,
2140
+ replacedByPlaceholder: replaced.replacedByPlaceholder,
2141
+ beforeCounts: before.countsByPlaceholder,
2142
+ afterCounts: after.countsByPlaceholder,
2143
+ },
2144
+ actions: {
2145
+ create: true,
2146
+ updateText: textUpdated,
2147
+ publish: false,
2148
+ },
2149
+ },
2150
+ };
2151
+ }
2152
+
2153
+ let published = false;
2154
+ let publishApplied = false;
2155
+ if (publishRequested) {
2156
+ const publishRes = await ctx.client.call(
2157
+ "documents.update",
2158
+ {
2159
+ id: documentId,
2160
+ publish: true,
2161
+ },
2162
+ { maxAttempts }
2163
+ );
2164
+ finalPayload = maybeDropPolicies(publishRes.body, !!args.includePolicies);
2165
+ updatedDoc = publishRes.body?.data || updatedDoc;
2166
+ published = true;
2167
+ publishApplied = true;
2168
+ }
2169
+
2170
+ return {
2171
+ tool: "documents.create_from_template",
2172
+ profile: ctx.profile.id,
2173
+ result: {
2174
+ success: true,
2175
+ strictPlaceholders,
2176
+ publishRequested,
2177
+ published,
2178
+ document: normalizeTemplatePipelineDocument(finalPayload?.data || updatedDoc, view),
2179
+ placeholders: {
2180
+ providedKeys: providedPlaceholderKeys,
2181
+ unresolved,
2182
+ unresolvedCount: unresolved.length,
2183
+ totalBefore: before.tokenCount,
2184
+ totalAfter: after.tokenCount,
2185
+ replacedByPlaceholder: replaced.replacedByPlaceholder,
2186
+ },
2187
+ actions: {
2188
+ create: true,
2189
+ updateText: textUpdated,
2190
+ publish: publishApplied,
2191
+ },
2192
+ },
2193
+ };
2194
+ }
2195
+
2196
+ function normalizeCommentContent(row, maxChars = 200) {
2197
+ const direct = [row?.text, row?.content, row?.anchorText];
2198
+ for (const value of direct) {
2199
+ const compacted = compactText(value, maxChars);
2200
+ if (compacted) {
2201
+ return compacted;
2202
+ }
2203
+ }
2204
+
2205
+ if (Object.prototype.hasOwnProperty.call(row || {}, "data")) {
2206
+ try {
2207
+ return compactText(JSON.stringify(stableObject(row?.data ?? null)), maxChars);
2208
+ } catch {
2209
+ return "";
2210
+ }
2211
+ }
2212
+
2213
+ return "";
2214
+ }
2215
+
2216
+ function normalizeCommentQueueRow(row, contentChars = 200) {
2217
+ const parentCommentId = row?.parentCommentId ? String(row.parentCommentId) : "";
2218
+ const createdAt = row?.createdAt ? String(row.createdAt) : "";
2219
+ const updatedAt = row?.updatedAt ? String(row.updatedAt) : createdAt;
2220
+ return {
2221
+ commentId: row?.id ? String(row.id) : "",
2222
+ documentId: row?.documentId ? String(row.documentId) : row?.document?.id ? String(row.document.id) : "",
2223
+ parentCommentId,
2224
+ createdAt,
2225
+ updatedAt,
2226
+ isReply: parentCommentId.length > 0,
2227
+ content: normalizeCommentContent(row, contentChars),
2228
+ };
2229
+ }
2230
+
2231
+ function normalizeManifestRow(doc) {
2232
+ return {
2233
+ id: doc?.id ? String(doc.id) : "",
2234
+ title: doc?.title ? String(doc.title) : "",
2235
+ updatedAt: doc?.updatedAt ? String(doc.updatedAt) : "",
2236
+ publishedAt: doc?.publishedAt ? String(doc.publishedAt) : "",
2237
+ collectionId: doc?.collectionId ? String(doc.collectionId) : "",
2238
+ urlId: doc?.urlId ? String(doc.urlId) : "",
2239
+ };
2240
+ }
2241
+
2242
+ function normalizeProbeRanking(value, index) {
2243
+ const numeric = Number(value);
2244
+ if (Number.isFinite(numeric)) {
2245
+ return numeric;
2246
+ }
2247
+ return Math.max(0, 1000 - index);
2248
+ }
2249
+
2250
+ function normalizeProbeTitleHit(row, index) {
2251
+ const doc = normalizeManifestRow(row);
2252
+ if (!doc.id) {
2253
+ return null;
2254
+ }
2255
+ return {
2256
+ ...doc,
2257
+ ranking: normalizeProbeRanking(row?.ranking, index),
2258
+ source: "titles",
2259
+ context: "",
2260
+ };
2261
+ }
2262
+
2263
+ function normalizeProbeSemanticHit(row, index) {
2264
+ const doc = normalizeManifestRow(row?.document || row);
2265
+ if (!doc.id) {
2266
+ return null;
2267
+ }
2268
+ return {
2269
+ ...doc,
2270
+ ranking: normalizeProbeRanking(row?.ranking, index),
2271
+ source: "semantic",
2272
+ context: compactText(row?.context || "", 280),
2273
+ };
2274
+ }
2275
+
2276
+ function mergeProbeHits(hits, hitLimit) {
2277
+ const byId = new Map();
2278
+
2279
+ for (const hit of hits) {
2280
+ if (!hit?.id) {
2281
+ continue;
2282
+ }
2283
+ const existing = byId.get(hit.id);
2284
+ if (!existing) {
2285
+ byId.set(hit.id, {
2286
+ id: hit.id,
2287
+ title: hit.title,
2288
+ collectionId: hit.collectionId,
2289
+ updatedAt: hit.updatedAt,
2290
+ publishedAt: hit.publishedAt,
2291
+ urlId: hit.urlId,
2292
+ ranking: hit.ranking,
2293
+ sources: [hit.source],
2294
+ context: hit.context,
2295
+ });
2296
+ continue;
2297
+ }
2298
+
2299
+ existing.ranking = Math.max(existing.ranking, hit.ranking);
2300
+ if (!existing.sources.includes(hit.source)) {
2301
+ existing.sources.push(hit.source);
2302
+ existing.sources.sort((a, b) => a.localeCompare(b));
2303
+ }
2304
+ if (compareIsoDesc(existing.updatedAt, hit.updatedAt) > 0) {
2305
+ existing.updatedAt = hit.updatedAt;
2306
+ existing.publishedAt = hit.publishedAt;
2307
+ }
2308
+ if (!existing.context && hit.context) {
2309
+ existing.context = hit.context;
2310
+ }
2311
+ }
2312
+
2313
+ return Array.from(byId.values())
2314
+ .sort((a, b) => {
2315
+ if (b.ranking !== a.ranking) {
2316
+ return b.ranking - a.ranking;
2317
+ }
2318
+ const updatedCmp = compareIsoDesc(a.updatedAt, b.updatedAt);
2319
+ if (updatedCmp !== 0) {
2320
+ return updatedCmp;
2321
+ }
2322
+ return compareIdAsc(a.id, b.id);
2323
+ })
2324
+ .slice(0, hitLimit)
2325
+ .map((hit, index) => ({
2326
+ rank: index + 1,
2327
+ ...hit,
2328
+ }));
2329
+ }
2330
+
2331
+ function summarizePolicies(policies = []) {
2332
+ const truthy = new Set();
2333
+ const falsy = new Set();
2334
+
2335
+ for (const policy of policies || []) {
2336
+ const abilities = policy?.abilities;
2337
+ if (!abilities || typeof abilities !== "object") {
2338
+ continue;
2339
+ }
2340
+ for (const [ability, enabled] of Object.entries(abilities)) {
2341
+ if (enabled) {
2342
+ truthy.add(String(ability));
2343
+ } else {
2344
+ falsy.add(String(ability));
2345
+ }
2346
+ }
2347
+ }
2348
+
2349
+ return {
2350
+ policyCount: Array.isArray(policies) ? policies.length : 0,
2351
+ truthyAbilities: Array.from(truthy).sort((a, b) => a.localeCompare(b)),
2352
+ falsyAbilities: Array.from(falsy).sort((a, b) => a.localeCompare(b)),
2353
+ };
2354
+ }
2355
+
2356
+ function normalizeUserMembershipRow(row) {
2357
+ return {
2358
+ id: row?.id ? String(row.id) : "",
2359
+ userId: row?.userId ? String(row.userId) : row?.user?.id ? String(row.user.id) : "",
2360
+ permission: row?.permission ? String(row.permission) : "",
2361
+ name: row?.user?.name ? String(row.user.name) : "",
2362
+ email: row?.user?.email ? String(row.user.email) : "",
2363
+ updatedAt: row?.updatedAt ? String(row.updatedAt) : "",
2364
+ };
2365
+ }
2366
+
2367
+ function normalizeGroupMembershipRow(row) {
2368
+ return {
2369
+ id: row?.id ? String(row.id) : "",
2370
+ groupId: row?.groupId ? String(row.groupId) : row?.group?.id ? String(row.group.id) : "",
2371
+ permission: row?.permission ? String(row.permission) : "",
2372
+ name: row?.group?.name ? String(row.group.name) : "",
2373
+ updatedAt: row?.updatedAt ? String(row.updatedAt) : "",
2374
+ };
2375
+ }
2376
+
2377
+ function sortMembershipRows(rows) {
2378
+ return rows.sort((a, b) => {
2379
+ const permissionCmp = String(a.permission || "").localeCompare(String(b.permission || ""));
2380
+ if (permissionCmp !== 0) {
2381
+ return permissionCmp;
2382
+ }
2383
+ const principalCmp = String(a.userId || a.groupId || "").localeCompare(String(b.userId || b.groupId || ""));
2384
+ if (principalCmp !== 0) {
2385
+ return principalCmp;
2386
+ }
2387
+ return compareIdAsc(a.id, b.id);
2388
+ });
2389
+ }
2390
+
2391
+ async function listCollectionDocumentIds(ctx, collectionId, maxAttempts) {
2392
+ const pageLimit = 100;
2393
+ const maxDocuments = 200;
2394
+ const ids = [];
2395
+ const seen = new Set();
2396
+ let offset = 0;
2397
+ let truncated = false;
2398
+
2399
+ while (ids.length < maxDocuments) {
2400
+ const res = await ctx.client.call(
2401
+ "documents.list",
2402
+ {
2403
+ collectionId,
2404
+ limit: pageLimit,
2405
+ offset,
2406
+ sort: "updatedAt",
2407
+ direction: "DESC",
2408
+ },
2409
+ { maxAttempts }
2410
+ );
2411
+
2412
+ const rows = Array.isArray(res.body?.data) ? res.body.data : [];
2413
+ for (const row of rows) {
2414
+ const id = row?.id ? String(row.id) : "";
2415
+ if (!id || seen.has(id)) {
2416
+ continue;
2417
+ }
2418
+ seen.add(id);
2419
+ ids.push(id);
2420
+ if (ids.length >= maxDocuments) {
2421
+ truncated = true;
2422
+ break;
2423
+ }
2424
+ }
2425
+
2426
+ if (rows.length < pageLimit || truncated) {
2427
+ break;
2428
+ }
2429
+
2430
+ offset += pageLimit;
2431
+ }
2432
+
2433
+ return {
2434
+ ids,
2435
+ truncated,
2436
+ };
2437
+ }
2438
+
2439
+ async function commentsReviewQueueTool(ctx, args = {}) {
2440
+ const explicitIds = uniqueStrings(ensureStringArray(args.documentIds, "documentIds") || []);
2441
+ const collectionId = args.collectionId ? String(args.collectionId) : "";
2442
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
2443
+ const includeReplies = args.includeReplies !== false;
2444
+ const includeAnchorText = !!args.includeAnchorText;
2445
+ const limitPerDocument = Math.max(1, Math.min(200, toInteger(args.limitPerDocument, 30)));
2446
+ const view = args.view === "full" ? "full" : "summary";
2447
+
2448
+ if (explicitIds.length === 0 && !collectionId) {
2449
+ throw new CliError("comments.review_queue requires args.documentIds[] or args.collectionId");
2450
+ }
2451
+
2452
+ let documentIds = explicitIds;
2453
+ let collectionScopeTruncated = false;
2454
+ if (documentIds.length === 0 && collectionId) {
2455
+ const resolved = await listCollectionDocumentIds(ctx, collectionId, maxAttempts);
2456
+ documentIds = resolved.ids;
2457
+ collectionScopeTruncated = resolved.truncated;
2458
+ }
2459
+
2460
+ const perDocument = await mapLimit(documentIds, Math.min(6, Math.max(1, documentIds.length || 1)), async (documentId) => {
2461
+ try {
2462
+ const res = await ctx.client.call(
2463
+ "comments.list",
2464
+ {
2465
+ documentId,
2466
+ includeAnchorText,
2467
+ includeReplies,
2468
+ limit: limitPerDocument,
2469
+ offset: 0,
2470
+ sort: "updatedAt",
2471
+ direction: "DESC",
2472
+ },
2473
+ { maxAttempts }
2474
+ );
2475
+ const sourceRows = Array.isArray(res.body?.data) ? res.body.data : [];
2476
+ const rows = sourceRows
2477
+ .map((row) => normalizeCommentQueueRow(row, 220))
2478
+ .filter((row) => row.commentId && row.documentId);
2479
+ return {
2480
+ documentId,
2481
+ ok: true,
2482
+ rowCount: rows.length,
2483
+ truncated: sourceRows.length >= limitPerDocument,
2484
+ rows,
2485
+ sourceRows: view === "full" ? sourceRows : undefined,
2486
+ };
2487
+ } catch (err) {
2488
+ if (err instanceof ApiError) {
2489
+ return {
2490
+ documentId,
2491
+ ok: false,
2492
+ error: err.message,
2493
+ status: err.details.status,
2494
+ };
2495
+ }
2496
+ throw err;
2497
+ }
2498
+ });
2499
+
2500
+ const failures = perDocument.filter((item) => !item.ok);
2501
+ const successRows = perDocument.filter((item) => item.ok);
2502
+ const deduped = new Map();
2503
+ for (const item of successRows) {
2504
+ for (const row of item.rows) {
2505
+ const existing = deduped.get(row.commentId);
2506
+ if (!existing || compareIsoDesc(existing.updatedAt, row.updatedAt) > 0) {
2507
+ deduped.set(row.commentId, row);
2508
+ }
2509
+ }
2510
+ }
2511
+
2512
+ const rows = Array.from(deduped.values()).sort((a, b) => {
2513
+ const updatedCmp = compareIsoDesc(a.updatedAt, b.updatedAt);
2514
+ if (updatedCmp !== 0) {
2515
+ return updatedCmp;
2516
+ }
2517
+ const createdCmp = compareIsoDesc(a.createdAt, b.createdAt);
2518
+ if (createdCmp !== 0) {
2519
+ return createdCmp;
2520
+ }
2521
+ return compareIdAsc(a.commentId, b.commentId);
2522
+ });
2523
+
2524
+ return {
2525
+ tool: "comments.review_queue",
2526
+ profile: ctx.profile.id,
2527
+ result: {
2528
+ scope: {
2529
+ documentIds,
2530
+ collectionId,
2531
+ },
2532
+ includeReplies,
2533
+ includeAnchorText,
2534
+ limitPerDocument,
2535
+ documentCount: documentIds.length,
2536
+ rowCount: rows.length,
2537
+ failedDocumentCount: failures.length,
2538
+ truncated: collectionScopeTruncated || successRows.some((item) => item.truncated),
2539
+ rows,
2540
+ failures: failures.map((item) => ({
2541
+ documentId: item.documentId,
2542
+ error: item.error,
2543
+ status: item.status,
2544
+ })),
2545
+ perDocument:
2546
+ view === "full"
2547
+ ? successRows.map((item) => ({
2548
+ documentId: item.documentId,
2549
+ rowCount: item.rowCount,
2550
+ truncated: item.truncated,
2551
+ comments: item.sourceRows,
2552
+ }))
2553
+ : undefined,
2554
+ },
2555
+ };
2556
+ }
2557
+
2558
+ async function federatedSyncManifestTool(ctx, args = {}) {
2559
+ const query = typeof args.query === "string" ? args.query.trim() : "";
2560
+ const collectionId = args.collectionId ? String(args.collectionId) : "";
2561
+ const includeDrafts = args.includeDrafts === true;
2562
+ const limit = Math.max(1, Math.min(250, toInteger(args.limit, 50)));
2563
+ const offset = Math.max(0, toInteger(args.offset, 0));
2564
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
2565
+ const since = normalizeIsoTimestamp(args.since, "since");
2566
+
2567
+ const method = query ? "documents.search_titles" : "documents.list";
2568
+ const body = compactValue({
2569
+ query: query || undefined,
2570
+ collectionId: collectionId || undefined,
2571
+ limit,
2572
+ offset,
2573
+ sort: "updatedAt",
2574
+ direction: "DESC",
2575
+ statusFilter: includeDrafts ? undefined : ["published"],
2576
+ }) || {};
2577
+
2578
+ const res = await ctx.client.call(method, body, { maxAttempts });
2579
+ const rawRows = Array.isArray(res.body?.data) ? res.body.data : [];
2580
+ let rows = rawRows.map((row) => normalizeManifestRow(row)).filter((row) => row.id);
2581
+
2582
+ if (since) {
2583
+ rows = rows.filter((row) => row.updatedAt && row.updatedAt >= since);
2584
+ }
2585
+
2586
+ rows.sort((a, b) => {
2587
+ const updatedCmp = compareIsoDesc(a.updatedAt, b.updatedAt);
2588
+ if (updatedCmp !== 0) {
2589
+ return updatedCmp;
2590
+ }
2591
+ return compareIdAsc(a.id, b.id);
2592
+ });
2593
+
2594
+ const hasMore = rawRows.length === limit;
2595
+ return {
2596
+ tool: "federated.sync_manifest",
2597
+ profile: ctx.profile.id,
2598
+ result: {
2599
+ mode: query ? "search_titles" : "documents.list",
2600
+ query,
2601
+ collectionId,
2602
+ since,
2603
+ includeDrafts,
2604
+ pagination: {
2605
+ limit,
2606
+ offset,
2607
+ hasMore,
2608
+ nextOffset: hasMore ? offset + limit : offset + rawRows.length,
2609
+ },
2610
+ rowCount: rows.length,
2611
+ rows,
2612
+ },
2613
+ };
2614
+ }
2615
+
2616
+ function normalizeProbeQueries(args = {}) {
2617
+ const rawQueries = [];
2618
+ if (args.query != null) {
2619
+ rawQueries.push(args.query);
2620
+ }
2621
+ for (const query of ensureStringArray(args.queries, "queries") || []) {
2622
+ rawQueries.push(query);
2623
+ }
2624
+ return uniqueStrings(rawQueries);
2625
+ }
2626
+
2627
+ async function federatedSyncProbeTool(ctx, args = {}) {
2628
+ const queries = normalizeProbeQueries(args);
2629
+ if (queries.length === 0) {
2630
+ throw new CliError("federated.sync_probe requires args.query or args.queries[]");
2631
+ }
2632
+
2633
+ const mode = args.mode === "titles" || args.mode === "semantic" ? args.mode : "both";
2634
+ const limit = Math.max(1, Math.min(100, toInteger(args.limit, 10)));
2635
+ const offset = Math.max(0, toInteger(args.offset, 0));
2636
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
2637
+ const collectionId = args.collectionId ? String(args.collectionId) : "";
2638
+ const includeTitles = mode === "titles" || mode === "both";
2639
+ const includeSemantic = mode === "semantic" || mode === "both";
2640
+ const queryConcurrency = Math.min(4, Math.max(1, queries.length));
2641
+
2642
+ const perQuery = await mapLimit(queries, queryConcurrency, async (query) => {
2643
+ const hits = [];
2644
+ const errors = [];
2645
+ const baseBody = compactValue({
2646
+ query,
2647
+ collectionId: collectionId || undefined,
2648
+ limit,
2649
+ offset,
2650
+ }) || {};
2651
+
2652
+ if (includeTitles) {
2653
+ try {
2654
+ const titlesRes = await ctx.client.call("documents.search_titles", baseBody, { maxAttempts });
2655
+ const rows = Array.isArray(titlesRes.body?.data) ? titlesRes.body.data : [];
2656
+ for (let i = 0; i < rows.length; i += 1) {
2657
+ const normalized = normalizeProbeTitleHit(rows[i], i);
2658
+ if (normalized) {
2659
+ hits.push(normalized);
2660
+ }
2661
+ }
2662
+ } catch (err) {
2663
+ if (err instanceof ApiError) {
2664
+ errors.push({
2665
+ source: "titles",
2666
+ error: err.message,
2667
+ status: err.details.status,
2668
+ });
2669
+ } else {
2670
+ throw err;
2671
+ }
2672
+ }
2673
+ }
2674
+
2675
+ if (includeSemantic) {
2676
+ try {
2677
+ const semanticRes = await ctx.client.call(
2678
+ "documents.search",
2679
+ {
2680
+ ...baseBody,
2681
+ snippetMinWords: toInteger(args.snippetMinWords, 16),
2682
+ snippetMaxWords: toInteger(args.snippetMaxWords, 24),
2683
+ },
2684
+ { maxAttempts }
2685
+ );
2686
+ const rows = Array.isArray(semanticRes.body?.data) ? semanticRes.body.data : [];
2687
+ for (let i = 0; i < rows.length; i += 1) {
2688
+ const normalized = normalizeProbeSemanticHit(rows[i], i);
2689
+ if (normalized) {
2690
+ hits.push(normalized);
2691
+ }
2692
+ }
2693
+ } catch (err) {
2694
+ if (err instanceof ApiError) {
2695
+ errors.push({
2696
+ source: "semantic",
2697
+ error: err.message,
2698
+ status: err.details.status,
2699
+ });
2700
+ } else {
2701
+ throw err;
2702
+ }
2703
+ }
2704
+ }
2705
+
2706
+ const rankedHits = mergeProbeHits(hits, limit);
2707
+ return {
2708
+ query,
2709
+ found: rankedHits.length > 0,
2710
+ missing: rankedHits.length === 0,
2711
+ hitCount: rankedHits.length,
2712
+ hits: rankedHits,
2713
+ errors,
2714
+ };
2715
+ });
2716
+
2717
+ const found = perQuery.filter((item) => item.found).map((item) => item.query);
2718
+ const missing = perQuery.filter((item) => item.missing).map((item) => item.query);
2719
+
2720
+ return {
2721
+ tool: "federated.sync_probe",
2722
+ profile: ctx.profile.id,
2723
+ result: {
2724
+ mode,
2725
+ collectionId,
2726
+ limit,
2727
+ offset,
2728
+ queryCount: queries.length,
2729
+ found,
2730
+ missing,
2731
+ perQuery,
2732
+ },
2733
+ };
2734
+ }
2735
+
2736
+ function normalizePermissionIds(args = {}) {
2737
+ const ids = [];
2738
+ if (args.id != null) {
2739
+ ids.push(args.id);
2740
+ }
2741
+ for (const id of ensureStringArray(args.ids, "ids") || []) {
2742
+ ids.push(id);
2743
+ }
2744
+ return uniqueStrings(ids);
2745
+ }
2746
+
2747
+ async function resolvePermissionIdsFromQueries(ctx, args, maxAttempts) {
2748
+ const queries = normalizeProbeQueries(args);
2749
+ if (queries.length === 0) {
2750
+ return {
2751
+ queries: [],
2752
+ ids: [],
2753
+ perQuery: [],
2754
+ };
2755
+ }
2756
+
2757
+ const limitPerQuery = Math.max(1, Math.min(50, toInteger(args.limitPerQuery, 10)));
2758
+ const offset = Math.max(0, toInteger(args.offset, 0));
2759
+ const collectionId = args.collectionId ? String(args.collectionId) : "";
2760
+ const ids = [];
2761
+ const seen = new Set();
2762
+ const perQuery = [];
2763
+
2764
+ for (const query of queries) {
2765
+ try {
2766
+ const res = await ctx.client.call(
2767
+ "documents.search_titles",
2768
+ compactValue({
2769
+ query,
2770
+ collectionId: collectionId || undefined,
2771
+ limit: limitPerQuery,
2772
+ offset,
2773
+ }) || {},
2774
+ { maxAttempts }
2775
+ );
2776
+ const rows = Array.isArray(res.body?.data) ? res.body.data : [];
2777
+ const hits = rows.map((row) => normalizeManifestRow(row)).filter((row) => row.id);
2778
+ for (const hit of hits) {
2779
+ if (seen.has(hit.id)) {
2780
+ continue;
2781
+ }
2782
+ seen.add(hit.id);
2783
+ ids.push(hit.id);
2784
+ }
2785
+ perQuery.push({
2786
+ query,
2787
+ hitCount: hits.length,
2788
+ hits,
2789
+ });
2790
+ } catch (err) {
2791
+ if (err instanceof ApiError) {
2792
+ perQuery.push({
2793
+ query,
2794
+ hitCount: 0,
2795
+ hits: [],
2796
+ error: err.message,
2797
+ status: err.details.status,
2798
+ });
2799
+ } else {
2800
+ throw err;
2801
+ }
2802
+ }
2803
+ }
2804
+
2805
+ return {
2806
+ queries,
2807
+ ids,
2808
+ perQuery,
2809
+ };
2810
+ }
2811
+
2812
+ async function safeMembershipCall(ctx, method, body, maxAttempts, normalizer) {
2813
+ try {
2814
+ const res = await ctx.client.call(method, body, { maxAttempts });
2815
+ const rows = Array.isArray(res.body?.data) ? res.body.data.map(normalizer) : [];
2816
+ return {
2817
+ ok: true,
2818
+ count: rows.length,
2819
+ rows: sortMembershipRows(rows),
2820
+ };
2821
+ } catch (err) {
2822
+ if (err instanceof ApiError) {
2823
+ return {
2824
+ ok: false,
2825
+ count: 0,
2826
+ rows: [],
2827
+ error: err.message,
2828
+ status: err.details.status,
2829
+ };
2830
+ }
2831
+ throw err;
2832
+ }
2833
+ }
2834
+
2835
+ async function federatedPermissionSnapshotTool(ctx, args = {}) {
2836
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
2837
+ const includeDocumentMemberships = args.includeDocumentMemberships !== false;
2838
+ const includeCollectionMemberships = args.includeCollectionMemberships !== false;
2839
+ const membershipLimit = Math.max(1, Math.min(250, toInteger(args.membershipLimit, 100)));
2840
+ const readConcurrency = Math.max(1, Math.min(8, toInteger(args.concurrency, 3)));
2841
+
2842
+ const explicitIds = normalizePermissionIds(args);
2843
+ const resolved = explicitIds.length > 0 ? { queries: [], ids: [], perQuery: [] } : await resolvePermissionIdsFromQueries(ctx, args, maxAttempts);
2844
+ const targetIds = uniqueStrings([...explicitIds, ...(resolved.ids || [])]);
2845
+
2846
+ if (targetIds.length === 0) {
2847
+ throw new CliError("federated.permission_snapshot requires args.id/args.ids or query/queries resolving to documents");
2848
+ }
2849
+
2850
+ const items = await mapLimit(targetIds, readConcurrency, async (id) => {
2851
+ try {
2852
+ const info = await ctx.client.call("documents.info", { id }, { maxAttempts });
2853
+ const doc = info.body?.data || {};
2854
+ const document = normalizeManifestRow(doc);
2855
+ document.title = doc?.title ? String(doc.title) : "";
2856
+
2857
+ const policies = summarizePolicies(Array.isArray(info.body?.policies) ? info.body.policies : []);
2858
+ const collectionId = document.collectionId;
2859
+
2860
+ const documentUsers = includeDocumentMemberships
2861
+ ? await safeMembershipCall(
2862
+ ctx,
2863
+ "documents.memberships",
2864
+ { id, limit: membershipLimit, offset: 0 },
2865
+ maxAttempts,
2866
+ normalizeUserMembershipRow
2867
+ )
2868
+ : { ok: true, count: 0, rows: [] };
2869
+
2870
+ const documentGroups = includeDocumentMemberships
2871
+ ? await safeMembershipCall(
2872
+ ctx,
2873
+ "documents.group_memberships",
2874
+ { id, limit: membershipLimit, offset: 0 },
2875
+ maxAttempts,
2876
+ normalizeGroupMembershipRow
2877
+ )
2878
+ : { ok: true, count: 0, rows: [] };
2879
+
2880
+ const collectionUsers = includeCollectionMemberships && collectionId
2881
+ ? await safeMembershipCall(
2882
+ ctx,
2883
+ "collections.memberships",
2884
+ { id: collectionId, limit: membershipLimit, offset: 0 },
2885
+ maxAttempts,
2886
+ normalizeUserMembershipRow
2887
+ )
2888
+ : { ok: true, count: 0, rows: [] };
2889
+
2890
+ const collectionGroups = includeCollectionMemberships && collectionId
2891
+ ? await safeMembershipCall(
2892
+ ctx,
2893
+ "collections.group_memberships",
2894
+ { id: collectionId, limit: membershipLimit, offset: 0 },
2895
+ maxAttempts,
2896
+ normalizeGroupMembershipRow
2897
+ )
2898
+ : { ok: true, count: 0, rows: [] };
2899
+
2900
+ const errors = [];
2901
+ for (const [scope, payload] of Object.entries({
2902
+ documentUsers,
2903
+ documentGroups,
2904
+ collectionUsers,
2905
+ collectionGroups,
2906
+ })) {
2907
+ if (!payload.ok) {
2908
+ errors.push({
2909
+ scope,
2910
+ error: payload.error,
2911
+ status: payload.status,
2912
+ });
2913
+ }
2914
+ }
2915
+
2916
+ return {
2917
+ id,
2918
+ ok: errors.length === 0,
2919
+ document,
2920
+ policySnapshot: policies,
2921
+ memberships: {
2922
+ documentUsers: {
2923
+ count: documentUsers.count,
2924
+ rows: documentUsers.rows,
2925
+ },
2926
+ documentGroups: {
2927
+ count: documentGroups.count,
2928
+ rows: documentGroups.rows,
2929
+ },
2930
+ collectionUsers: {
2931
+ count: collectionUsers.count,
2932
+ rows: collectionUsers.rows,
2933
+ },
2934
+ collectionGroups: {
2935
+ count: collectionGroups.count,
2936
+ rows: collectionGroups.rows,
2937
+ },
2938
+ },
2939
+ errors,
2940
+ };
2941
+ } catch (err) {
2942
+ if (err instanceof ApiError) {
2943
+ return {
2944
+ id,
2945
+ ok: false,
2946
+ error: err.message,
2947
+ status: err.details.status,
2948
+ };
2949
+ }
2950
+ throw err;
2951
+ }
2952
+ });
2953
+
2954
+ const failed = items.filter((item) => !item.ok).length;
2955
+
2956
+ return {
2957
+ tool: "federated.permission_snapshot",
2958
+ profile: ctx.profile.id,
2959
+ result: {
2960
+ requestedIds: explicitIds,
2961
+ resolvedQueryCount: resolved.queries?.length || 0,
2962
+ resolvedFromQueries: resolved.perQuery,
2963
+ total: items.length,
2964
+ succeeded: items.length - failed,
2965
+ failed,
2966
+ items,
2967
+ },
2968
+ };
2969
+ }
2970
+
2971
+ export const EXTENDED_TOOLS = {
2972
+ ...RPC_TOOLS,
2973
+ "documents.answer": {
2974
+ signature:
2975
+ "documents.answer(args: { question?: string; query?: string; ...endpointArgs; includePolicies?: boolean; maxAttempts?: number })",
2976
+ description: "Answer a question using Outline AI over the selected document scope.",
2977
+ usageExample: {
2978
+ tool: "documents.answer",
2979
+ args: {
2980
+ question: "What changed in our onboarding checklist?",
2981
+ collectionId: "collection-id",
2982
+ },
2983
+ },
2984
+ bestPractices: [
2985
+ "Use question text that is specific enough to resolve citations quickly.",
2986
+ "Scope by collectionId or documentId to reduce latency and hallucination risk.",
2987
+ ],
2988
+ handler: documentsAnswerTool,
2989
+ },
2990
+ "documents.answer_batch": {
2991
+ signature:
2992
+ "documents.answer_batch(args: { question?: string; questions?: Array<string | { question?: string; query?: string; ...endpointArgs }>; ...endpointArgs; concurrency?: number; includePolicies?: boolean; maxAttempts?: number })",
2993
+ description: "Run multiple documents.answerQuestion calls with per-item isolation.",
2994
+ usageExample: {
2995
+ tool: "documents.answer_batch",
2996
+ args: {
2997
+ questions: [
2998
+ "Where is the release checklist?",
2999
+ "Who owns incident postmortems?",
3000
+ ],
3001
+ collectionId: "collection-id",
3002
+ concurrency: 2,
3003
+ },
3004
+ },
3005
+ bestPractices: [
3006
+ "Prefer small batches and low concurrency for predictable token and latency budgets.",
3007
+ "Use per-item statuses to retry only failures.",
3008
+ ],
3009
+ handler: documentsAnswerBatchTool,
3010
+ },
3011
+ "documents.backlinks": {
3012
+ signature:
3013
+ "documents.backlinks(args: { id: string; limit?: number; offset?: number; sort?: string; direction?: 'ASC'|'DESC'; view?: 'ids'|'summary'|'full'; includePolicies?: boolean; maxAttempts?: number })",
3014
+ description: "List backlinks for a document via documents.list(backlinkDocumentId=id).",
3015
+ usageExample: {
3016
+ tool: "documents.backlinks",
3017
+ args: {
3018
+ id: "doc-1",
3019
+ limit: 20,
3020
+ view: "summary",
3021
+ },
3022
+ },
3023
+ bestPractices: [
3024
+ "Use view=ids for low-token planning loops, then hydrate specific documents separately.",
3025
+ "Use limit/offset pagination for deterministic traversal over large backlink sets.",
3026
+ ],
3027
+ handler: documentsBacklinksTool,
3028
+ },
3029
+ "documents.graph_neighbors": {
3030
+ signature:
3031
+ "documents.graph_neighbors(args: { id?: string; ids?: string[]; includeBacklinks?: boolean; includeSearchNeighbors?: boolean; searchQueries?: string[]; limitPerSource?: number; view?: 'ids'|'summary'|'full'; maxAttempts?: number })",
3032
+ description: "Collect one-hop graph neighbors and deterministic edge rows for source document IDs.",
3033
+ usageExample: {
3034
+ tool: "documents.graph_neighbors",
3035
+ args: {
3036
+ id: "doc-1",
3037
+ includeBacklinks: true,
3038
+ includeSearchNeighbors: true,
3039
+ searchQueries: ["incident response"],
3040
+ limitPerSource: 8,
3041
+ },
3042
+ },
3043
+ bestPractices: [
3044
+ "Start with a single source id and small limitPerSource, then expand incrementally.",
3045
+ "Enable includeSearchNeighbors only when additional semantic neighborhood expansion is needed.",
3046
+ ],
3047
+ handler: documentsGraphNeighborsTool,
3048
+ },
3049
+ "documents.graph_report": {
3050
+ signature:
3051
+ "documents.graph_report(args: { seedIds: string[]; depth?: number; maxNodes?: number; includeBacklinks?: boolean; includeSearchNeighbors?: boolean; limitPerSource?: number; view?: 'ids'|'summary'|'full'; maxAttempts?: number })",
3052
+ description: "Build a bounded BFS graph report with stable nodes[] and edges[] output.",
3053
+ usageExample: {
3054
+ tool: "documents.graph_report",
3055
+ args: {
3056
+ seedIds: ["doc-1", "doc-2"],
3057
+ depth: 2,
3058
+ maxNodes: 120,
3059
+ includeBacklinks: true,
3060
+ includeSearchNeighbors: false,
3061
+ limitPerSource: 10,
3062
+ },
3063
+ },
3064
+ bestPractices: [
3065
+ "Cap maxNodes and depth to keep traversal deterministic and cost-bounded.",
3066
+ "Prefer view=ids for graph planning and fetch full nodes only for selected IDs.",
3067
+ ],
3068
+ handler: documentsGraphReportTool,
3069
+ },
3070
+ "documents.issue_refs": {
3071
+ signature:
3072
+ "documents.issue_refs(args: { id?: string; ids?: string[]; issueDomains?: string[]; keyPattern?: string; view?: 'ids'|'summary'|'full'; maxAttempts?: number })",
3073
+ description:
3074
+ "Extract deterministic issue references (URL links and key-pattern matches) from one or more documents.",
3075
+ usageExample: {
3076
+ tool: "documents.issue_refs",
3077
+ args: {
3078
+ ids: ["doc-1", "doc-2"],
3079
+ issueDomains: ["jira.example.com", "github.com"],
3080
+ keyPattern: "[A-Z][A-Z0-9]+-\\\\d+",
3081
+ view: "summary",
3082
+ },
3083
+ },
3084
+ bestPractices: [
3085
+ "Start with view=ids for low-token audits, then re-run selected docs with summary/full views.",
3086
+ "Provide issueDomains to reduce non-issue URL noise and keep outputs focused.",
3087
+ "Tune keyPattern when your tracker uses custom issue key formats.",
3088
+ ],
3089
+ handler: documentsIssueRefsTool,
3090
+ },
3091
+ "documents.issue_ref_report": {
3092
+ signature:
3093
+ "documents.issue_ref_report(args: { query?: string; queries?: string[]; collectionId?: string; issueDomains?: string[]; keyPattern?: string; limit?: number; view?: 'ids'|'summary'|'full'; maxAttempts?: number })",
3094
+ description:
3095
+ "Resolve candidate documents from title+semantic search, then extract deterministic issue reference summaries.",
3096
+ usageExample: {
3097
+ tool: "documents.issue_ref_report",
3098
+ args: {
3099
+ queries: ["incident response", "release checklist"],
3100
+ collectionId: "collection-id",
3101
+ issueDomains: ["jira.example.com"],
3102
+ limit: 12,
3103
+ view: "summary",
3104
+ },
3105
+ },
3106
+ bestPractices: [
3107
+ "Use specific queries to keep the candidate set small and deterministic.",
3108
+ "Scope by collectionId when possible to avoid cross-workspace noise.",
3109
+ "Review perQuery errors before treating missing issue refs as definitive.",
3110
+ ],
3111
+ handler: documentsIssueRefReportTool,
3112
+ },
3113
+ "documents.import_file": {
3114
+ signature:
3115
+ "documents.import_file(args: { filePath: string; collectionId?: string; parentDocumentId?: string; publish?: boolean; contentType?: string; includePolicies?: boolean; maxAttempts?: number; performAction?: boolean; ...endpointArgs })",
3116
+ description:
3117
+ "Upload a local file as multipart/form-data to documents.import while preserving deterministic output envelopes.",
3118
+ usageExample: {
3119
+ tool: "documents.import_file",
3120
+ args: {
3121
+ filePath: "./tmp/wiki-export.md",
3122
+ collectionId: "collection-id",
3123
+ publish: false,
3124
+ performAction: true,
3125
+ },
3126
+ },
3127
+ bestPractices: [
3128
+ "Provide exactly one placement target when needed: collectionId or parentDocumentId.",
3129
+ "Use file_operations.info to poll async import status after documents.import_file returns.",
3130
+ "This tool is action-gated; set performAction=true only for explicitly confirmed mutations.",
3131
+ ],
3132
+ handler: documentsImportFileTool,
3133
+ },
3134
+ "templates.extract_placeholders": {
3135
+ signature: "templates.extract_placeholders(args: { id: string; maxAttempts?: number })",
3136
+ description: "Extract sorted unique placeholder keys ({{key}}) from template text nodes.",
3137
+ usageExample: {
3138
+ tool: "templates.extract_placeholders",
3139
+ args: {
3140
+ id: "template-id",
3141
+ },
3142
+ },
3143
+ bestPractices: [
3144
+ "Run this before document creation to validate required placeholder keys.",
3145
+ "Use counts to catch repeated placeholders for deterministic pipeline checks.",
3146
+ ],
3147
+ handler: templatesExtractPlaceholdersTool,
3148
+ },
3149
+ "documents.create_from_template": {
3150
+ signature:
3151
+ "documents.create_from_template(args: { templateId: string; title?: string; collectionId?: string; parentDocumentId?: string; publish?: boolean; placeholderValues?: Record<string,string>; strictPlaceholders?: boolean; view?: 'summary'|'full'; includePolicies?: boolean; maxAttempts?: number; performAction?: boolean })",
3152
+ description:
3153
+ "Create from template, optionally inject placeholder values, and enforce strict unresolved-placeholder safety.",
3154
+ usageExample: {
3155
+ tool: "documents.create_from_template",
3156
+ args: {
3157
+ templateId: "template-id",
3158
+ title: "Service A - Incident Postmortem",
3159
+ placeholderValues: {
3160
+ service_name: "Service A",
3161
+ owner: "SRE Team",
3162
+ },
3163
+ strictPlaceholders: true,
3164
+ publish: true,
3165
+ performAction: true,
3166
+ },
3167
+ },
3168
+ bestPractices: [
3169
+ "Keep strictPlaceholders=true in automation to prevent publishing unresolved template tokens.",
3170
+ "Provide placeholderValues as exact key-value strings and inspect unresolvedCount on every run.",
3171
+ "This tool is action-gated; set performAction=true only for explicitly confirmed mutations.",
3172
+ ],
3173
+ handler: documentsCreateFromTemplateTool,
3174
+ },
3175
+ "comments.review_queue": {
3176
+ signature:
3177
+ "comments.review_queue(args: { documentIds?: string[]; collectionId?: string; includeAnchorText?: boolean; includeReplies?: boolean; limitPerDocument?: number; view?: 'summary'|'full'; maxAttempts?: number })",
3178
+ description: "Build a deterministic comment review queue from comments.list responses.",
3179
+ usageExample: {
3180
+ tool: "comments.review_queue",
3181
+ args: {
3182
+ documentIds: ["doc-1", "doc-2"],
3183
+ includeReplies: true,
3184
+ limitPerDocument: 20,
3185
+ },
3186
+ },
3187
+ bestPractices: [
3188
+ "Scope to explicit documentIds whenever possible for predictable queue size.",
3189
+ "Use includeReplies=true to capture full threaded review context.",
3190
+ "Treat truncated=true as a signal to re-run with a higher limitPerDocument.",
3191
+ ],
3192
+ handler: commentsReviewQueueTool,
3193
+ },
3194
+ "federated.sync_manifest": {
3195
+ signature:
3196
+ "federated.sync_manifest(args?: { collectionId?: string; query?: string; since?: string; limit?: number; offset?: number; includeDrafts?: boolean; maxAttempts?: number })",
3197
+ description: "Generate deterministic document manifest rows for federated index sync workflows.",
3198
+ usageExample: {
3199
+ tool: "federated.sync_manifest",
3200
+ args: {
3201
+ collectionId: "collection-id",
3202
+ since: "2026-03-01T00:00:00.000Z",
3203
+ limit: 100,
3204
+ offset: 0,
3205
+ },
3206
+ },
3207
+ bestPractices: [
3208
+ "Use `since` + pagination for incremental sync jobs.",
3209
+ "Use includeDrafts=false for published-only downstream indexes.",
3210
+ "Persist pagination.nextOffset and resume deterministically.",
3211
+ ],
3212
+ handler: federatedSyncManifestTool,
3213
+ },
3214
+ "federated.sync_probe": {
3215
+ signature:
3216
+ "federated.sync_probe(args: { query?: string; queries?: string[]; mode?: 'titles'|'semantic'|'both'; collectionId?: string; limit?: number; offset?: number; maxAttempts?: number })",
3217
+ description: "Probe document findability across title and semantic search with per-query ranked hits.",
3218
+ usageExample: {
3219
+ tool: "federated.sync_probe",
3220
+ args: {
3221
+ queries: ["runbook escalation", "incident policy"],
3222
+ mode: "both",
3223
+ limit: 8,
3224
+ },
3225
+ },
3226
+ bestPractices: [
3227
+ "Use both mode when validating search behavior before external index reconciliation.",
3228
+ "Inspect perQuery[].errors for partial-mode failures before alerting.",
3229
+ "Track missing[] over time for regression detection.",
3230
+ ],
3231
+ handler: federatedSyncProbeTool,
3232
+ },
3233
+ "federated.permission_snapshot": {
3234
+ signature:
3235
+ "federated.permission_snapshot(args: { id?: string; ids?: string[]; query?: string; queries?: string[]; collectionId?: string; includeDocumentMemberships?: boolean; includeCollectionMemberships?: boolean; limitPerQuery?: number; membershipLimit?: number; concurrency?: number; maxAttempts?: number })",
3236
+ description: "Capture per-document permission and membership snapshots for federated ACL reconciliation.",
3237
+ usageExample: {
3238
+ tool: "federated.permission_snapshot",
3239
+ args: {
3240
+ ids: ["doc-1", "doc-2"],
3241
+ includeDocumentMemberships: true,
3242
+ includeCollectionMemberships: true,
3243
+ },
3244
+ },
3245
+ bestPractices: [
3246
+ "Pass explicit ids for deterministic ACL snapshots.",
3247
+ "Use query/queries only when you need dynamic resolution before snapshotting.",
3248
+ "Inspect item.errors for scoped permission gaps instead of failing whole runs.",
3249
+ ],
3250
+ handler: federatedPermissionSnapshotTool,
3251
+ },
3252
+ };