pi-supernova 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
+ import { runProgramBatch } from "./src/runtime/program-batch.js";
3
+ import { REFERENCE } from "./src/runtime/reference.js";
2
4
  import { isString, isFunction } from "./src/shared/decode.js";
3
5
  import { loadConfig } from "./src/config/config.js";
4
6
  import { createHostBridge } from "./src/bridge/host-bridge.js";
5
- import { truncateChars } from "./src/output/format.js";
7
+ import { truncateChars, formatBoundedStringArray } from "./src/output/format.js";
6
8
  import { runGuestProgram, warmGuestWorker, stopWarmGuestWorker } from "./src/runtime/runtime.js";
7
9
  import { renderSupernovaCall, renderSupernovaResult } from "./src/ui/render.js";
8
10
 
@@ -10,13 +12,17 @@ export { renderSupernovaCall, renderSupernovaResult };
10
12
 
11
13
  // Sync only, never top-level await. Dynamic import of host/deps hung OMP plugin load.
12
14
  const require = createRequire(import.meta.url);
15
+
13
16
  let Type;
17
+
14
18
  try {
15
19
  Type = require("typebox").Type;
16
20
  } catch {
17
21
  Type = {
18
22
  Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
19
23
  String: (opts) => ({ type: "string", ...opts }),
24
+ Unknown: (opts) => ({ ...opts }),
25
+ Array: (items, opts) => ({ type: "array", items, ...opts }),
20
26
  Integer: (opts) => ({ type: "integer", ...opts }),
21
27
  Optional: (s) => ({ ...s }),
22
28
  };
@@ -38,30 +44,38 @@ export function progressEmitter(onUpdate) {
38
44
  let pending = null;
39
45
  let timer = null;
40
46
  let lastSent = -Infinity;
47
+
41
48
  const send = () => {
42
49
  timer = null;
50
+
43
51
  if (pending === null) return;
44
52
  // Snapshot only at emission, not on every tool event. Completed records must
45
53
  // not mutate a previously emitted frame while Pi is still consuming it.
46
54
  const trace = pending.map(record => ({ ...record }));
47
55
  pending = null;
48
56
  lastSent = performance.now();
57
+
49
58
  try {
50
59
  onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
51
60
  } catch {}
52
61
  };
62
+
53
63
  const emit = (trace) => {
54
64
  pending = trace;
65
+
55
66
  if (timer !== null) return;
56
67
  const wait = PROGRESS_FRAME_MS - (performance.now() - lastSent);
68
+
57
69
  if (wait <= 0) send();
58
70
  else timer = setTimeout(send, wait);
59
71
  };
72
+
60
73
  emit.flush = () => {
61
74
  if (timer !== null) clearTimeout(timer);
62
75
  pending = null;
63
76
  timer = null;
64
77
  };
78
+
65
79
  return emit;
66
80
  }
67
81
 
@@ -70,36 +84,70 @@ function sessionStats({ programs, returnedChars }) {
70
84
  }
71
85
 
72
86
  function logsBlock(outcome, tail = "") {
73
- return outcome.logs?.length ? `\n--- logs\n${outcome.logs.join("\n")}${tail}` : "";
87
+ if (!outcome.logs?.length && !outcome.logTruncated) return "";
88
+
89
+ return `\n--- logs${outcome.logTruncated ? " [logs truncated]" : ""}\n${outcome.logs?.join("\n") ?? ""}${tail}`;
90
+ }
91
+
92
+ function mutationText(outcome) {
93
+ const m = outcome.mutations;
94
+
95
+ if (!m) return "";
96
+ const external = m.external ? "; external calls attempted=" + m.external + ", their side effects cannot be rolled back" : "";
97
+ const uncertain = m.pendingCommits || m.recoveryFailed ? "; filesystem outcome uncertain: inspect disk and any recovery backups before retrying" : "";
98
+
99
+ return "\nmutations: committed=" + m.committed + " rolledBack=" + m.rolledBack + " (file versions)" + external + uncertain;
100
+ }
101
+
102
+ function mutationReceipts(trace) {
103
+ if (!Array.isArray(trace)) return "";
104
+
105
+ return trace
106
+ .filter(row => row?.ok && (row.name === "write" || row.name === "edit") && isString(row.resultText) && row.resultText)
107
+ .map(row => row.resultText)
108
+ .join("\n");
109
+ }
110
+
111
+ // Corrective hint, emitted only when a turn actually split. Independent work
112
+ // belongs in one program: a split cannot use the single prewarmed worker and pays
113
+ // one extra spawn per sibling. Costs nothing until it fires, so it needs no room in
114
+ // the tool definition.
115
+ function splitTurnHint(outcome) {
116
+ return outcome.overlappedTurn ? ` (${outcome.overlappedTurn} supernova calls ran at once; independent work belongs in one program)` : "";
74
117
  }
75
118
 
76
119
  function errorText(outcome, call) {
77
- return `error #${call} ${outcome.wallMs}ms: ${outcome.error}${logsBlock(outcome)}`;
120
+ return `error #${call} ${outcome.wallMs}ms${outcome.returnTruncated ? " [output truncated]" : ""}${mutationText(outcome)}${splitTurnHint(outcome)}
121
+ error: ${outcome.error}${logsBlock(outcome)}`;
78
122
  }
79
123
 
80
124
  function successText(outcome, call) {
81
125
  const truncated = outcome.returnTruncated ? " [return truncated]" : "";
82
126
  const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
83
- return `ok #${call} ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
127
+
128
+ return `ok #${call} ${outcome.wallMs}ms${truncated}${outcome.mutations?.committed || outcome.mutations?.rolledBack || outcome.mutations?.external ? mutationText(outcome) : ""}${splitTurnHint(outcome)}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
129
+ }
130
+
131
+ function fitOutput(outcome, call, limit, format) {
132
+ let text = format(outcome, call);
133
+
134
+ if (text.length <= limit) return text;
135
+ outcome.returnTruncated = true;
136
+ const wrapper = format({ ...outcome, resultText: "", logs: [] }, call);
137
+ const room = Math.max(256, limit - wrapper.length);
138
+
139
+ if (Array.isArray(outcome.result) && outcome.result.length && outcome.result.every(isString)) {
140
+ outcome.resultText = formatBoundedStringArray(outcome.result, room);
141
+ } else if (isString(outcome.resultText) && outcome.resultText.length > room) {
142
+ outcome.resultText = truncateChars(outcome.resultText, room, "output").text;
143
+ }
144
+
145
+ text = format(outcome, call);
146
+
147
+ return text.length <= limit ? text : truncateChars(text, limit, "output").text;
84
148
  }
85
- const TOOL_DESCRIPTION = `Run one JavaScript program with four familiar commands: read, write, edit, bash. Use an async body or arrow. Return a small value; strings stay raw.
86
-
87
- Native commands (async):
88
- read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
89
- read("symbol or question") → locate and open source in one call, without an index; selected file text stays raw
90
- read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
91
- read(path, {about: question}) → relevant file bodies, or source selection inside a directory
92
- read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
93
- write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
94
- edit(path, oldText, newText) → post-edit lines, checks, and references
95
- edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
96
- bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
97
- bash({command, args:[...]}) → literal argv without shell expansion of arguments
98
-
99
- Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true for structured status checks; narrow the directory with path+about when uncertain.
100
- For read-modify-write, use read({path,complete:true}); it rejects partial output. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
101
- Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
102
- Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.`;
149
+
150
+ const TOOL_DESCRIPTION = REFERENCE;
103
151
 
104
152
  export default function piSupernova(pi) {
105
153
  registerCodeMode(pi);
@@ -107,12 +155,23 @@ export default function piSupernova(pi) {
107
155
 
108
156
  // Shared entry used by both host adapters and direct engine integration.
109
157
  export function registerCodeMode(pi) {
110
- // Local cache residency cannot establish what remains in the model's context.
111
- const config = { ...loadConfig(), seenWindow: 0 };
158
+ // Citation elision is experimental and disabled by default. A context event is
159
+ // not the final provider payload: hidden details or later transforms can invalidate
160
+ // a citation. A positive seenWindow explicitly opts in despite those limitations.
161
+ const config = loadConfig();
112
162
  let cwd = process.cwd();
113
163
  let programSeq = 0;
114
164
  let stopped = false;
115
165
  let warmTimer;
166
+ // Program runs currently executing. Only one pristine worker is ever prewarmed,
167
+ // so concurrent invocations cannot share it and each sibling pays a fresh spawn.
168
+ // Counting them lets a result say so without adding standing guidance to the
169
+ // tool definition, which is resent on every request.
170
+ let inFlight = 0;
171
+ // Peak concurrent execute() bodies in the current wave. Start-order or
172
+ // finish-order alone cannot see a first-started call that finishes last.
173
+ let overlapPeak = 0;
174
+
116
175
  function cancelWarmTimer() {
117
176
  if (warmTimer !== undefined) clearImmediate(warmTimer);
118
177
  warmTimer = undefined;
@@ -148,28 +207,34 @@ export function registerCodeMode(pi) {
148
207
  name: "supernova",
149
208
  label: "Supernova",
150
209
  description: TOOL_DESCRIPTION,
151
- promptSnippet: "Use read, write, edit, and bash in one program",
152
- promptGuidelines: [
153
- "Use read, write, edit, and bash inside supernova. Start with read(question), or read(directory, {about: question}) for scoped source selection. A source question already opens the selected file; do not issue a redundant read. Use read({query,resolve:true}) and check status before editing its path. Explicit about/outline/evidence reads remain available when needed. Return a compact value.",
154
- ],
210
+ promptSnippet: "read, write, edit, bash",
155
211
  parameters: Type.Object({
156
- code: Type.String({ maxLength: config.maxCodeChars ?? 48000, description: `JavaScript program: async body or arrow function. Maximum ${config.maxCodeChars ?? 48000} UTF-16 code units; split large writes into write({path,content,append:true}) chunks.` }),
157
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
158
- }, { required: ["code"] }),
212
+ code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
213
+ file: Type.Optional(Type.String({ minLength: 1 })),
214
+ data: Type.Optional(Type.Unknown()),
215
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
216
+ programs: Type.Optional(Type.Array(Type.Object({
217
+ code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
218
+ file: Type.Optional(Type.String({ minLength: 1 })),
219
+ data: Type.Optional(Type.Unknown()),
220
+ }, {additionalProperties:false}), {minItems:1,maxItems:32})),
221
+ }),
159
222
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
160
223
  // so separate call/result slots cannot duplicate the lifecycle card.
161
224
  renderShell: "self",
162
225
  mergeCallAndResult: true,
163
226
  renderCall: renderSupernovaCall,
164
227
  renderResult: renderSupernovaResult,
165
- async execute(_id, params, signal, onUpdate, ctx) {
228
+ execute: async function execute(_id, params, signal, onUpdate, ctx, budget) {
229
+ if (params?.programs !== undefined) return runProgramBatch(_id,params,signal,onUpdate,ctx,config,execute);
166
230
  cancelWarmTimer();
167
231
  const runCwd = ctx?.cwd || cwd;
168
232
  const runController = new AbortController();
169
233
  const abortRun = () => runController.abort(signal?.reason);
234
+
170
235
  if (signal?.aborted) abortRun();
171
236
  else signal?.addEventListener("abort", abortRun, { once: true });
172
- const runBridge = bridge.fork({ getCwd: () => runCwd });
237
+ const runBridge = bridge.fork({ getCwd: () => runCwd, budget });
173
238
  runBridge.bindCallContext(ctx, runController.signal);
174
239
  runBridge.resetCallBudget();
175
240
 
@@ -180,61 +245,108 @@ export function registerCodeMode(pi) {
180
245
  emitProgress([]);
181
246
  const started = performance.now();
182
247
  let outcome;
248
+ inFlight += 1;
249
+ overlapPeak = Math.max(overlapPeak, inFlight);
250
+ let peakSeen = overlapPeak;
251
+
183
252
  try {
184
253
  refreshCatalog(runBridge);
185
254
  runBridge.beginSpeculation();
186
255
  outcome = await runGuestProgram({
187
256
  code: params?.code,
257
+ file: params?.file,
258
+ cwd: runCwd,
259
+ data: params?.data,
188
260
  nova: makeNovaApi(runBridge, abortRun),
189
- config: { ...config, timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
261
+ config: { ...config, maxLogLines: Math.max(0,config.maxLogLines-(budget?.logLines ?? 0)), timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
190
262
  signal: runController.signal,
191
263
  onTimeout: abortRun,
192
264
  });
193
265
  runBridge.close();
266
+
194
267
  if (outcome.ok) {
195
268
  if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
196
269
  await runBridge.commitSpeculation();
197
270
  }
198
- else runBridge.rollbackSpeculation();
271
+ else while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
199
272
  } catch (error) {
200
273
  abortRun();
201
274
  runBridge.close();
202
- runBridge.rollbackSpeculation();
275
+
276
+ while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
203
277
  outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
204
278
  } finally {
279
+ peakSeen = Math.max(peakSeen, overlapPeak);
280
+ inFlight -= 1;
281
+ if (inFlight === 0) overlapPeak = 0;
205
282
  runBridge.setCallListener(null);
206
283
  emitProgress.flush();
207
284
  signal?.removeEventListener("abort", abortRun);
208
285
  // Prepare one pristine worker during the model's next decision. Never
209
286
  // recycle a worker that has executed arbitrary guest JavaScript.
210
287
  cancelWarmTimer();
288
+
211
289
  if (!stopped && !runController.signal.aborted) {
212
290
  // Deliver the result before paying for another Worker constructor.
213
291
  warmTimer = setImmediate(() => {
214
292
  warmTimer = undefined;
293
+
215
294
  if (!stopped && !runController.signal.aborted) warmGuestWorker(config).catch(() => {});
216
295
  });
217
296
  warmTimer.unref?.();
218
297
  }
219
298
  }
299
+
300
+ if (budget) budget.logLines += outcome.logs?.length ?? 0;
301
+ outcome.overlappedTurn = peakSeen > 1 ? peakSeen : 0;
302
+ outcome.mutations = runBridge.getMutations();
220
303
  const trace = runBridge.getTrace();
221
- const text = outcome.ok ? successText(outcome, call) : errorText(outcome, call);
222
- const bounded = truncateChars(text, config.maxReturnChars, "output").text;
304
+
305
+ if (outcome.ok && outcome.result === undefined) {
306
+ const receipts = mutationReceipts(trace);
307
+
308
+ if (receipts) {
309
+ outcome.resultText = receipts;
310
+ outcome.undefinedReturn = false;
311
+ }
312
+ }
313
+ const format = outcome.ok ? successText : errorText;
314
+ const bounded = fitOutput(outcome, call, config.maxReturnChars, format);
223
315
  const visible = runBridge.ledger.dedupe(bounded, call);
224
- if (!outcome.ok) throw new Error(visible);
316
+
225
317
  const response = result(visible, {
226
318
  ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
227
319
  returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
228
- logs: outcome.logs, result: outcome.result, trace,
320
+ logs: outcome.logs, result: outcome.result, trace, mutations: outcome.mutations,
229
321
  });
322
+
230
323
  if (outcome.images?.length) response.content.push(...outcome.images);
324
+
325
+ if (!outcome.ok) {
326
+ const error = new Error(visible);
327
+ Object.defineProperty(error,"supernovaResult",{value:response});
328
+ throw error;
329
+ }
330
+
231
331
  return response;
232
332
  },
233
333
  });
234
334
 
235
- pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer(); return stopWarmGuestWorker(); });
335
+ // This is a pre-conversion observation, not a final-payload retention proof.
336
+ // Shipping seenWindow:0 must not subscribe: a no-op listener still runs on every
337
+ // provider context event. Opt-in windows register here.
338
+ if ((config.seenWindow ?? 0) > 0) {
339
+ pi.on("context", event => {
340
+ try { bridge.ledger.observe(event?.messages); } catch {}
341
+ });
342
+ }
343
+
344
+ pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer();
345
+
346
+ return stopWarmGuestWorker(); });
236
347
  pi.on("session_start", (_event, ctx) => {
237
348
  stopped = false;
349
+
238
350
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
239
351
  // A new session is a new model context: nothing has been seen yet.
240
352
  bridge.bindCallContext(ctx);
@@ -250,11 +362,13 @@ export function registerCodeMode(pi) {
250
362
  bridge.bindCallContext(ctx);
251
363
  refreshCatalog();
252
364
  const commands = ["read", "edit", "write", "bash"].filter(bridge.isCallable);
365
+
253
366
  const lines = [
254
367
  `Supernova CodeMode: ${commands.join(", ")}`,
255
368
  `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
256
369
  sessionStats(bridge.ledger.stats),
257
370
  ];
371
+
258
372
  ctx.ui.notify(lines.join("\n"), "info");
259
373
  },
260
374
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "One CodeMode invocation for Pi and OMP, with four guest commands, automatic read batching and source context.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -33,6 +33,7 @@
33
33
  "test": "node --test tests/*/*.test.mjs",
34
34
  "test:hosts": "node tests/hosts/verify.mjs",
35
35
  "measure": "node tests/efficiency/measure.mjs",
36
+ "test:tokens": "node tests/efficiency/tokens.mjs",
36
37
  "prepublishOnly": "npm test && node ../../scripts/preflight.mjs"
37
38
  },
38
39
  "pi": {
@@ -58,6 +59,7 @@
58
59
  }
59
60
  },
60
61
  "devDependencies": {
62
+ "js-tiktoken": "1.0.21",
61
63
  "typebox": "^1.0.0"
62
64
  },
63
65
  "publishConfig": {
@@ -4,7 +4,7 @@ import { isString, isObject } from "../shared/decode.js";
4
4
  const NATIVE_TOOL_DEFINITIONS = [
5
5
  {
6
6
  name: "read",
7
- description: "Read files or directories. Source questions locate and open source directly; resolve returns structured source/status without guessing.",
7
+ description: "Read files, images or directories. JSON selectors project full documents within output budgets. Source questions locate and open source directly; resolve returns structured source/status without guessing.",
8
8
  parameters: { type: "object", properties: {
9
9
  path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Workspace-relative file or directory, source question, or array of paths" },
10
10
  target: { anyOf: [{ type: "string" }, { type: "array" }], description: "File path/query or array of paths" },
@@ -14,6 +14,7 @@ const NATIVE_TOOL_DEFINITIONS = [
14
14
  query: { type: "string", description: "Source question; optional path scopes the search directory" },
15
15
  resolve: { type: "boolean", description: "Return structured source/status for a direct resolve-to-edit handoff" },
16
16
  complete: { type: "boolean", description: "Fail unless the entire requested file fits without clipping" },
17
+ json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" } }], description: "Parse the complete JSON input (up to 16 MiB), then select .field, .items[0:3], or quoted keys. A selector array returns an array of values; true selects the root. Oversized selections fail, never clip." },
17
18
  } },
18
19
  },
19
20
  {
@@ -85,28 +86,36 @@ export function mergeNativeToolDefinitions(tools, capturedNames = []) {
85
86
  merged.push(fallback && !captured.has(tool.name)
86
87
  ? { ...tool, ...fallback, sourceInfo: { path: "<native:" + tool.name + ">" } }
87
88
  : tool);
89
+
88
90
  if (tool?.name) seen.add(tool.name);
89
91
  }
92
+
90
93
  for (const fallback of NATIVE_TOOL_DEFINITIONS) {
91
94
  if (!seen.has(fallback.name)) {
92
95
  merged.push({ ...fallback, sourceInfo: { path: "<native:" + fallback.name + ">" } });
93
96
  }
94
97
  }
98
+
95
99
  return merged;
96
100
  }
97
101
 
98
102
  function sourcePathOf(tool) {
99
103
  if (tool.sourceInfo && isString(tool.sourceInfo.path)) return tool.sourceInfo.path;
104
+
100
105
  if (isString(tool.extensionPath)) return tool.extensionPath;
106
+
101
107
  if (isString(tool.sourcePath)) return tool.sourcePath;
108
+
102
109
  return undefined;
103
110
  }
104
111
 
105
112
  function normalizeTool(tool) {
106
113
  if (!tool || !isObject(tool)) return null;
107
114
  const name = isString(tool.name) ? tool.name : "";
115
+
108
116
  if (!name) return null;
109
117
  const description = isString(tool.description) ? tool.description : "";
118
+
110
119
  return {
111
120
  name,
112
121
  nameLower: name.toLowerCase(),
@@ -121,12 +130,16 @@ function normalizeTool(tool) {
121
130
  export function buildCatalog(tools, excludeNames = []) {
122
131
  const exclude = new Set(excludeNames);
123
132
  const rows = [];
133
+
124
134
  for (const tool of tools || []) {
125
135
  const row = normalizeTool(tool);
136
+
126
137
  if (!row || exclude.has(row.name)) continue;
127
138
  rows.push(row);
128
139
  }
140
+
129
141
  rows.sort((a, b) => a.name.localeCompare(b.name));
142
+
130
143
  return rows;
131
144
  }
132
145
 
@@ -142,74 +155,94 @@ function scoreRow(row, tokens) {
142
155
  const name = row.nameLower || row.name.toLowerCase();
143
156
  const desc = row.descLower || row.description.toLowerCase();
144
157
  let score = 0;
158
+
145
159
  for (const token of tokens) {
146
160
  if (name === token) score += 10;
147
161
  else if (name.includes(token)) score += 5;
148
162
  else if (desc.includes(token)) score += 2;
149
163
  }
164
+
150
165
  return score;
151
166
  }
152
167
 
153
168
  export function searchCatalog(catalog, query, limit = 12) {
154
169
  const tokens = tokenize(query);
155
170
  const scored = [];
171
+
156
172
  for (const row of catalog) {
157
173
  const score = scoreRow(row, tokens);
174
+
158
175
  if (score <= 0 && tokens.length > 0) continue;
159
176
  scored.push({ name: row.name, description: row.description.slice(0, 160), score });
160
177
  }
178
+
161
179
  scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
180
+
162
181
  return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
163
182
  }
164
183
 
165
184
  /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
166
185
  function editDistance(a, b) {
167
186
  const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
187
+
168
188
  for (let j = 1; j <= b.length; j++) rows[0][j] = j;
189
+
169
190
  for (let i = 1; i <= a.length; i++) {
170
191
  for (let j = 1; j <= b.length; j++) {
171
192
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
172
193
  let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
194
+
173
195
  if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, rows[i - 2][j - 2] + 1);
174
196
  rows[i][j] = best;
175
197
  }
176
198
  }
199
+
177
200
  return rows[a.length][b.length];
178
201
  }
179
202
 
180
203
  /** Closest tool names for a mistyped name: substring hits first, then a length-scaled edit distance. */
181
204
  function suggestNames(name, candidates, limit = 3) {
182
205
  const needle = String(name || "").toLowerCase();
206
+
183
207
  if (!needle) return [];
184
208
  const maxDistance = Math.max(1, Math.floor(needle.length / 3));
185
209
  const scored = [];
210
+
186
211
  for (const candidate of candidates) {
187
212
  const lower = candidate.toLowerCase();
213
+
188
214
  if (lower === needle) continue;
189
215
  const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
216
+
190
217
  if (distance <= maxDistance) scored.push({ candidate, distance });
191
218
  }
219
+
192
220
  scored.sort(
193
221
  (a, b) =>
194
222
  a.distance - b.distance ||
195
223
  Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
196
224
  a.candidate.localeCompare(b.candidate),
197
225
  );
226
+
198
227
  return scored.slice(0, limit).map((s) => s.candidate);
199
228
  }
200
229
 
201
230
  export function unknownToolMessage(name, candidates) {
202
231
  const close = suggestNames(name, candidates);
203
232
  const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
233
+
204
234
  return `unknown tool "${name}".${hint} Check the command name and configured tool exclusions.`;
205
235
  }
206
236
 
207
237
  export function describeTool(catalog, name) {
208
238
  const row = catalog.find((t) => t.name === name);
239
+
209
240
  if (!row) {
210
241
  return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
211
242
  }
243
+
212
244
  if (!isObject(row.parameters)) return { ok: false, name, error: "tool schema unavailable: " + (row.schemaError ?? name) };
245
+
213
246
  if (!row._described) {
214
247
  row._described = {
215
248
  ok: true,
@@ -219,5 +252,6 @@ export function describeTool(catalog, name) {
219
252
  sourcePath: row.sourcePath,
220
253
  };
221
254
  }
255
+
222
256
  return row._described;
223
257
  }