@khanglvm/outline-cli 0.1.3 → 0.1.4

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.
@@ -11,12 +11,8 @@ const TYPES = {
11
11
  "string|string[]": (v) => typeof v === "string" || (Array.isArray(v) && v.every((x) => typeof x === "string")),
12
12
  };
13
13
 
14
- function fail(tool, issues) {
15
- throw new CliError(`Invalid args for ${tool}`, {
16
- code: "ARG_VALIDATION_FAILED",
17
- tool,
18
- issues,
19
- });
14
+ function fail(tool, issues, spec = {}, args = undefined) {
15
+ throw new CliError(`Invalid args for ${tool}`, buildValidationDetails(tool, spec, issues, args));
20
16
  }
21
17
 
22
18
  function ensureObject(tool, args) {
@@ -25,7 +21,248 @@ function ensureObject(tool, args) {
25
21
  }
26
22
  }
27
23
 
28
- function validateSpec(tool, args, spec) {
24
+ function levenshteinDistance(a, b) {
25
+ const left = String(a || "");
26
+ const right = String(b || "");
27
+ const rows = left.length + 1;
28
+ const cols = right.length + 1;
29
+ const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
30
+
31
+ for (let i = 0; i < rows; i += 1) {
32
+ matrix[i][0] = i;
33
+ }
34
+ for (let j = 0; j < cols; j += 1) {
35
+ matrix[0][j] = j;
36
+ }
37
+
38
+ for (let i = 1; i < rows; i += 1) {
39
+ for (let j = 1; j < cols; j += 1) {
40
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1;
41
+ matrix[i][j] = Math.min(
42
+ matrix[i - 1][j] + 1,
43
+ matrix[i][j - 1] + 1,
44
+ matrix[i - 1][j - 1] + cost
45
+ );
46
+ }
47
+ }
48
+
49
+ return matrix[rows - 1][cols - 1];
50
+ }
51
+
52
+ function buildAcceptedArgList(spec = {}) {
53
+ const accepted = new Set(Object.keys(spec.properties || {}));
54
+ accepted.add("compact");
55
+ return [...accepted].sort();
56
+ }
57
+
58
+ function suggestClosestArgNames(key, acceptedArgs) {
59
+ const raw = String(key || "").trim();
60
+ if (!raw) {
61
+ return [];
62
+ }
63
+
64
+ return acceptedArgs
65
+ .map((candidate) => ({
66
+ candidate,
67
+ distance: levenshteinDistance(raw.toLowerCase(), String(candidate).toLowerCase()),
68
+ }))
69
+ .filter((row) => row.distance <= 3)
70
+ .sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate))
71
+ .slice(0, 3)
72
+ .map((row) => row.candidate);
73
+ }
74
+
75
+ function enrichIssuesWithArgSuggestions(issues, acceptedArgs) {
76
+ return issues.map((issue) => {
77
+ if (!issue || issue.message !== "is not allowed" || typeof issue.path !== "string") {
78
+ return issue;
79
+ }
80
+ const key = issue.path.startsWith("args.") ? issue.path.slice(5) : issue.path;
81
+ const suggestions = suggestClosestArgNames(key, acceptedArgs);
82
+ return suggestions.length > 0 ? { ...issue, suggestions } : issue;
83
+ });
84
+ }
85
+
86
+ function extractArgKeyFromIssue(issue) {
87
+ if (!issue || typeof issue.path !== "string") {
88
+ return null;
89
+ }
90
+ return issue.path.startsWith("args.") ? issue.path.slice(5) : issue.path;
91
+ }
92
+
93
+ function applySuggestedArgFixes(args, issues) {
94
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
95
+ return undefined;
96
+ }
97
+
98
+ const next = { ...args };
99
+ let changed = false;
100
+
101
+ for (const issue of issues) {
102
+ if (!issue || issue.message !== "is not allowed") {
103
+ continue;
104
+ }
105
+ const key = extractArgKeyFromIssue(issue);
106
+ const target = Array.isArray(issue.suggestions) && issue.suggestions.length > 0
107
+ ? issue.suggestions[0]
108
+ : null;
109
+ if (!key || !target || !(key in next) || key === target) {
110
+ continue;
111
+ }
112
+ if (target in next && next[target] !== next[key]) {
113
+ continue;
114
+ }
115
+ if (!(target in next)) {
116
+ next[target] = next[key];
117
+ }
118
+ delete next[key];
119
+ changed = true;
120
+ }
121
+
122
+ return changed ? next : undefined;
123
+ }
124
+
125
+ function buildSuggestedArgs(spec, args, enrichedIssues) {
126
+ const candidate = applySuggestedArgFixes(args, enrichedIssues);
127
+ if (!candidate) {
128
+ return undefined;
129
+ }
130
+
131
+ const normalized = normalizeArgsForSpec(candidate, spec);
132
+ const remainingIssues = collectValidationIssues(normalized, spec);
133
+ return remainingIssues.length === 0 ? normalized : undefined;
134
+ }
135
+
136
+ function buildValidationDetails(tool, spec, issues, args = undefined) {
137
+ const acceptedArgs = buildAcceptedArgList(spec);
138
+ const enrichedIssues = enrichIssuesWithArgSuggestions(issues, acceptedArgs);
139
+ const requiredArgs = [...new Set(spec.required || [])].sort();
140
+ const unknownArgs = enrichedIssues
141
+ .filter((issue) => issue?.message === "is not allowed" && typeof issue.path === "string")
142
+ .map((issue) => issue.path.replace(/^args\./, ""));
143
+ const suggestedArgs = buildSuggestedArgs(spec, args, enrichedIssues);
144
+
145
+ return {
146
+ code: "ARG_VALIDATION_FAILED",
147
+ tool,
148
+ issues: enrichedIssues,
149
+ acceptedArgs,
150
+ requiredArgs,
151
+ unknownArgs,
152
+ suggestedArgs,
153
+ validationHint:
154
+ acceptedArgs.length > 0 ? `Accepted args: ${acceptedArgs.join(", ")}` : undefined,
155
+ };
156
+ }
157
+
158
+ function looksNumeric(value) {
159
+ return /^-?(?:\d+|\d+\.\d+)$/.test(String(value || "").trim());
160
+ }
161
+
162
+ function coerceScalarValue(types, value) {
163
+ if (typeof value !== "string") {
164
+ return value;
165
+ }
166
+
167
+ const trimmed = value.trim();
168
+ if (!trimmed) {
169
+ return value;
170
+ }
171
+
172
+ if (types.includes("boolean") && /^(true|false)$/i.test(trimmed)) {
173
+ return trimmed.toLowerCase() === "true";
174
+ }
175
+ if (types.includes("null") && /^null$/i.test(trimmed)) {
176
+ return null;
177
+ }
178
+ if (types.includes("number") && looksNumeric(trimmed)) {
179
+ return Number(trimmed);
180
+ }
181
+ if (types.includes("object") && /^[{]/.test(trimmed)) {
182
+ try {
183
+ return JSON.parse(trimmed);
184
+ } catch {
185
+ return value;
186
+ }
187
+ }
188
+ if (types.includes("array") && /^[[]/.test(trimmed)) {
189
+ try {
190
+ return JSON.parse(trimmed);
191
+ } catch {
192
+ return value;
193
+ }
194
+ }
195
+
196
+ return value;
197
+ }
198
+
199
+ function coerceArrayValue(types, value) {
200
+ if (!Array.isArray(value)) {
201
+ if (types.includes("string[]") && typeof value === "string") {
202
+ return [value];
203
+ }
204
+ return value;
205
+ }
206
+
207
+ if (types.includes("string[]")) {
208
+ return value.map((item) => (typeof item === "string" ? item : String(item)));
209
+ }
210
+
211
+ return value;
212
+ }
213
+
214
+ function normalizeCrossFieldAliases(args, properties) {
215
+ const next = { ...args };
216
+
217
+ if (Array.isArray(next.query) && properties.queries && next.queries === undefined) {
218
+ next.queries = next.query.map((item) => String(item));
219
+ delete next.query;
220
+ }
221
+ if (Array.isArray(next.question) && properties.questions && next.questions === undefined) {
222
+ next.questions = next.question.map((item) => String(item));
223
+ delete next.question;
224
+ }
225
+ if (Array.isArray(next.id) && properties.ids && next.ids === undefined) {
226
+ next.ids = next.id.map((item) => String(item));
227
+ delete next.id;
228
+ }
229
+ if (typeof next.ids === "string" && properties.id && !properties.ids && next.id === undefined) {
230
+ next.id = next.ids;
231
+ delete next.ids;
232
+ }
233
+ if (typeof next.queries === "string" && properties.query && !properties.queries && next.query === undefined) {
234
+ next.query = next.queries;
235
+ delete next.queries;
236
+ }
237
+
238
+ return next;
239
+ }
240
+
241
+ function normalizeArgsForSpec(args, spec) {
242
+ const properties = spec.properties || {};
243
+ const next = normalizeCrossFieldAliases(args, properties);
244
+
245
+ for (const [key, rule] of Object.entries(properties)) {
246
+ if (!(key in next)) {
247
+ continue;
248
+ }
249
+
250
+ const types = Array.isArray(rule.type) ? rule.type : [rule.type];
251
+ let value = next[key];
252
+ value = coerceArrayValue(types, value);
253
+ value = coerceScalarValue(types, value);
254
+
255
+ if (Array.isArray(value) && types.includes("string[]")) {
256
+ value = value.map((item) => (typeof item === "string" ? item : String(item)));
257
+ }
258
+
259
+ next[key] = value;
260
+ }
261
+
262
+ return next;
263
+ }
264
+
265
+ function collectValidationIssues(args, spec) {
29
266
  const issues = [];
30
267
  const properties = spec.properties || {};
31
268
 
@@ -81,8 +318,14 @@ function validateSpec(tool, args, spec) {
81
318
  spec.custom(args, issues);
82
319
  }
83
320
 
321
+ return issues;
322
+ }
323
+
324
+ function validateSpec(tool, args, spec) {
325
+ const issues = collectValidationIssues(args, spec);
326
+
84
327
  if (issues.length > 0) {
85
- fail(tool, issues);
328
+ fail(tool, issues, spec, args);
86
329
  }
87
330
  }
88
331
 
@@ -151,10 +394,19 @@ export const TOOL_ARG_SCHEMAS = {
151
394
  sort: { type: "string" },
152
395
  direction: { type: "string", enum: ["ASC", "DESC"] },
153
396
  parentDocumentId: { type: ["string", "null"] },
397
+ rootOnly: { type: "boolean" },
154
398
  backlinkDocumentId: { type: "string" },
155
399
  includePolicies: { type: "boolean" },
156
400
  ...SHARED_DOC_COMMON,
157
401
  },
402
+ custom(args, issues) {
403
+ if (args.rootOnly === true && Object.prototype.hasOwnProperty.call(args, "parentDocumentId") && args.parentDocumentId !== null) {
404
+ issues.push({
405
+ path: "args.rootOnly",
406
+ message: "cannot be combined with a non-null args.parentDocumentId",
407
+ });
408
+ }
409
+ },
158
410
  },
159
411
  "documents.backlinks": {
160
412
  required: ["id"],
@@ -2245,6 +2497,7 @@ export const TOOL_ARG_SCHEMAS = {
2245
2497
  dateFilter: { type: "string", enum: ["day", "week", "month", "year"] },
2246
2498
  includePolicies: { type: "boolean" },
2247
2499
  includeEvidenceDocs: { type: "boolean" },
2500
+ limit: { type: "number", min: 1 },
2248
2501
  view: { type: "string", enum: ["summary", "full"] },
2249
2502
  maxAttempts: { type: "number", min: 1 },
2250
2503
  },
@@ -2267,6 +2520,7 @@ export const TOOL_ARG_SCHEMAS = {
2267
2520
  dateFilter: { type: "string", enum: ["day", "week", "month", "year"] },
2268
2521
  includePolicies: { type: "boolean" },
2269
2522
  includeEvidenceDocs: { type: "boolean" },
2523
+ limit: { type: "number", min: 1 },
2270
2524
  view: { type: "string", enum: ["summary", "full"] },
2271
2525
  concurrency: { type: "number", min: 1 },
2272
2526
  maxAttempts: { type: "number", min: 1 },
@@ -2338,9 +2592,11 @@ export const TOOL_ARG_SCHEMAS = {
2338
2592
  export function validateToolArgs(toolName, args = {}) {
2339
2593
  const spec = TOOL_ARG_SCHEMAS[toolName];
2340
2594
  if (!spec) {
2341
- return;
2595
+ return args;
2342
2596
  }
2343
2597
 
2344
2598
  ensureObject(toolName, args);
2345
- validateSpec(toolName, args, spec);
2599
+ const normalizedArgs = normalizeArgsForSpec(args, spec);
2600
+ validateSpec(toolName, normalizedArgs, spec);
2601
+ return normalizedArgs;
2346
2602
  }
@@ -376,6 +376,87 @@ function parseQuestionItem(raw, index) {
376
376
  throw new CliError(`questions[${index}] must be string or object`);
377
377
  }
378
378
 
379
+ function isAnswerEndpointUnsupported(err) {
380
+ if (!(err instanceof ApiError)) {
381
+ return false;
382
+ }
383
+ if (err.details?.status !== 404) {
384
+ return false;
385
+ }
386
+ const url = String(err.details?.url || "").toLowerCase();
387
+ const message = String(err.message || "").toLowerCase();
388
+ return url.includes("documents.answerquestion") || message.includes("answerquestion");
389
+ }
390
+
391
+ function normalizeFallbackAnswerHit(row, contextChars = 220) {
392
+ const doc = row?.document || row || {};
393
+ const context = typeof row?.context === "string" ? row.context : "";
394
+ return compactValue({
395
+ id: doc.id,
396
+ title: doc.title,
397
+ collectionId: doc.collectionId,
398
+ parentDocumentId: doc.parentDocumentId,
399
+ updatedAt: doc.updatedAt,
400
+ publishedAt: doc.publishedAt,
401
+ urlId: doc.urlId,
402
+ ranking: row?.ranking,
403
+ context: context.length > contextChars ? `${context.slice(0, contextChars)}...` : context,
404
+ });
405
+ }
406
+
407
+ async function buildAnswerFallbackResult(ctx, question, args = {}) {
408
+ const maxAttempts = Math.max(1, toInteger(args.maxAttempts, 2));
409
+ const contextChars = Math.max(80, toInteger(args.contextChars, 220));
410
+ const limit = Math.max(1, Math.min(8, toInteger(args.limit, 5)));
411
+ const body = compactValue({
412
+ query: question,
413
+ collectionId: args.collectionId,
414
+ documentId: args.documentId || args.id,
415
+ userId: args.userId,
416
+ shareId: args.shareId,
417
+ statusFilter: args.statusFilter,
418
+ limit,
419
+ offset: 0,
420
+ snippetMinWords: toInteger(args.snippetMinWords, 20),
421
+ snippetMaxWords: toInteger(args.snippetMaxWords, 30),
422
+ }) || {};
423
+
424
+ const res = await ctx.client.call("documents.search", body, { maxAttempts });
425
+ const payload = maybeDropPolicies(res.body, !!args.includePolicies);
426
+ const hits = Array.isArray(payload?.data) ? payload.data : [];
427
+ const documents = hits.slice(0, Math.min(5, limit)).map((row) => normalizeFallbackAnswerHit(row, contextChars));
428
+
429
+ return {
430
+ question,
431
+ answer: "",
432
+ noAnswerReason: "documents.answerQuestion is unsupported by this Outline deployment; returning ranked retrieval results instead.",
433
+ unsupported: true,
434
+ fallbackUsed: true,
435
+ fallbackTool: "documents.search",
436
+ fallbackSuggestion: {
437
+ tool: "documents.search",
438
+ args: compactValue({
439
+ query: question,
440
+ collectionId: args.collectionId,
441
+ documentId: args.documentId || args.id,
442
+ userId: args.userId,
443
+ shareId: args.shareId,
444
+ statusFilter: args.statusFilter,
445
+ limit,
446
+ view: "summary",
447
+ }),
448
+ },
449
+ documents,
450
+ retrieval: {
451
+ query: question,
452
+ result: compactValue({
453
+ ...payload,
454
+ data: hits.map((row) => normalizeFallbackAnswerHit(row, contextChars)),
455
+ }),
456
+ },
457
+ };
458
+ }
459
+
379
460
  async function documentsAnswerTool(ctx, args = {}) {
380
461
  const question = String(args.question ?? args.query ?? "").trim();
381
462
  if (!question) {
@@ -383,23 +464,35 @@ async function documentsAnswerTool(ctx, args = {}) {
383
464
  }
384
465
 
385
466
  const body = {
386
- ...buildBody(args, ["question", "query"]),
467
+ ...buildBody(args, ["question", "query", "limit"]),
387
468
  query: question,
388
469
  };
389
470
 
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);
471
+ try {
472
+ const res = await ctx.client.call("documents.answerQuestion", body, {
473
+ maxAttempts: toInteger(args.maxAttempts, 2),
474
+ });
475
+ const payload = maybeDropPolicies(res.body, !!args.includePolicies);
394
476
 
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
- };
477
+ return {
478
+ tool: "documents.answer",
479
+ profile: ctx.profile.id,
480
+ result:
481
+ payload && typeof payload === "object"
482
+ ? { question, ...payload }
483
+ : { question, data: payload },
484
+ };
485
+ } catch (err) {
486
+ if (!isAnswerEndpointUnsupported(err)) {
487
+ throw err;
488
+ }
489
+
490
+ return {
491
+ tool: "documents.answer",
492
+ profile: ctx.profile.id,
493
+ result: await buildAnswerFallbackResult(ctx, question, args),
494
+ };
495
+ }
403
496
  }
404
497
 
405
498
  async function documentsAnswerBatchTool(ctx, args = {}) {
@@ -415,7 +508,7 @@ async function documentsAnswerBatchTool(ctx, args = {}) {
415
508
  throw new CliError("documents.answer_batch requires args.question or args.questions[]");
416
509
  }
417
510
 
418
- const baseBody = buildBody(args, ["question", "questions", "query", "concurrency"]);
511
+ const baseBody = buildBody(args, ["question", "questions", "query", "concurrency", "limit"]);
419
512
  const includePolicies = !!args.includePolicies;
420
513
  const maxAttempts = toInteger(args.maxAttempts, 2);
421
514
  const concurrency = Math.max(1, Math.min(10, toInteger(args.concurrency, 3)));
@@ -441,6 +534,18 @@ async function documentsAnswerBatchTool(ctx, args = {}) {
441
534
  result: payload,
442
535
  };
443
536
  } catch (err) {
537
+ if (parsed && isAnswerEndpointUnsupported(err)) {
538
+ return {
539
+ index,
540
+ ok: true,
541
+ question: parsed.question,
542
+ documentId: parsed.documentId,
543
+ result: await buildAnswerFallbackResult(ctx, parsed.question, {
544
+ ...args,
545
+ ...parsed.body,
546
+ }),
547
+ };
548
+ }
444
549
  if (err instanceof ApiError || err instanceof CliError) {
445
550
  return {
446
551
  index,
@@ -2972,7 +3077,7 @@ export const EXTENDED_TOOLS = {
2972
3077
  ...RPC_TOOLS,
2973
3078
  "documents.answer": {
2974
3079
  signature:
2975
- "documents.answer(args: { question?: string; query?: string; ...endpointArgs; includePolicies?: boolean; maxAttempts?: number })",
3080
+ "documents.answer(args: { question?: string; query?: string; limit?: number; ...endpointArgs; includePolicies?: boolean; maxAttempts?: number })",
2976
3081
  description: "Answer a question using Outline AI over the selected document scope.",
2977
3082
  usageExample: {
2978
3083
  tool: "documents.answer",
@@ -2984,12 +3089,13 @@ export const EXTENDED_TOOLS = {
2984
3089
  bestPractices: [
2985
3090
  "Use question text that is specific enough to resolve citations quickly.",
2986
3091
  "Scope by collectionId or documentId to reduce latency and hallucination risk.",
3092
+ "If the deployment lacks documents.answerQuestion, this wrapper returns fallback retrieval evidence and a concrete search suggestion instead of a raw 404.",
2987
3093
  ],
2988
3094
  handler: documentsAnswerTool,
2989
3095
  },
2990
3096
  "documents.answer_batch": {
2991
3097
  signature:
2992
- "documents.answer_batch(args: { question?: string; questions?: Array<string | { question?: string; query?: string; ...endpointArgs }>; ...endpointArgs; concurrency?: number; includePolicies?: boolean; maxAttempts?: number })",
3098
+ "documents.answer_batch(args: { question?: string; questions?: Array<string | { question?: string; query?: string; ...endpointArgs }>; limit?: number; ...endpointArgs; concurrency?: number; includePolicies?: boolean; maxAttempts?: number })",
2993
3099
  description: "Run multiple documents.answerQuestion calls with per-item isolation.",
2994
3100
  usageExample: {
2995
3101
  tool: "documents.answer_batch",
@@ -3005,6 +3111,7 @@ export const EXTENDED_TOOLS = {
3005
3111
  bestPractices: [
3006
3112
  "Prefer small batches and low concurrency for predictable token and latency budgets.",
3007
3113
  "Use per-item statuses to retry only failures.",
3114
+ "Unsupported answer endpoints degrade to per-item retrieval evidence rather than failing the whole batch.",
3008
3115
  ],
3009
3116
  handler: documentsAnswerBatchTool,
3010
3117
  },