pi-supernova 0.3.1 → 0.4.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,4 +1,6 @@
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";
@@ -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,35 +84,42 @@ 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
+ // Corrective hint, emitted only when a turn actually split. Independent work
103
+ // belongs in one program: a split cannot use the single prewarmed worker and pays
104
+ // one extra spawn per sibling. Costs nothing until it fires, so it needs no room in
105
+ // the tool definition.
106
+ function splitTurnHint(outcome) {
107
+ return outcome.overlappedTurn ? ` (${outcome.overlappedTurn} supernova calls ran at once; independent work belongs in one program)` : "";
74
108
  }
75
109
 
76
110
  function errorText(outcome, call) {
77
- return `error #${call} ${outcome.wallMs}ms: ${outcome.error}${logsBlock(outcome)}`;
111
+ return `error #${call} ${outcome.wallMs}ms${outcome.returnTruncated ? " [output truncated]" : ""}${mutationText(outcome)}${splitTurnHint(outcome)}
112
+ error: ${outcome.error}${logsBlock(outcome)}`;
78
113
  }
79
114
 
80
115
  function successText(outcome, call) {
81
116
  const truncated = outcome.returnTruncated ? " [return truncated]" : "";
82
117
  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}`;
118
+
119
+ 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}`;
84
120
  }
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
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
- Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
101
- 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.`;
121
+
122
+ const TOOL_DESCRIPTION = REFERENCE;
102
123
 
103
124
  export default function piSupernova(pi) {
104
125
  registerCodeMode(pi);
@@ -106,12 +127,20 @@ export default function piSupernova(pi) {
106
127
 
107
128
  // Shared entry used by both host adapters and direct engine integration.
108
129
  export function registerCodeMode(pi) {
109
- // Local cache residency cannot establish what remains in the model's context.
110
- const config = { ...loadConfig(), seenWindow: 0 };
130
+ // Citation elision is experimental and disabled by default. A context event is
131
+ // not the final provider payload: hidden details or later transforms can invalidate
132
+ // a citation. A positive seenWindow explicitly opts in despite those limitations.
133
+ const config = loadConfig();
111
134
  let cwd = process.cwd();
112
135
  let programSeq = 0;
113
136
  let stopped = false;
114
137
  let warmTimer;
138
+ // Program runs currently executing. Only one pristine worker is ever prewarmed,
139
+ // so concurrent invocations cannot share it and each sibling pays a fresh spawn.
140
+ // Counting them lets a result say so without adding standing guidance to the
141
+ // tool definition, which is resent on every request.
142
+ let inFlight = 0;
143
+
115
144
  function cancelWarmTimer() {
116
145
  if (warmTimer !== undefined) clearImmediate(warmTimer);
117
146
  warmTimer = undefined;
@@ -147,28 +176,34 @@ export function registerCodeMode(pi) {
147
176
  name: "supernova",
148
177
  label: "Supernova",
149
178
  description: TOOL_DESCRIPTION,
150
- promptSnippet: "Use read, write, edit, and bash in one program",
151
- promptGuidelines: [
152
- "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.",
153
- ],
179
+ promptSnippet: "JavaScript with read, write, edit, and bash",
154
180
  parameters: Type.Object({
155
- code: Type.String({ description: "JavaScript program: async body or arrow function." }),
181
+ code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
182
+ file: Type.Optional(Type.String({ minLength: 1 })),
183
+ data: Type.Optional(Type.Unknown({ description: "Literal JSON input available as data in the program; put Markdown, scripts or argv here instead of nesting JavaScript quoting. JSON-encoded size is limited to the code character budget." })),
156
184
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
157
- }, { required: ["code"] }),
185
+ programs: Type.Optional(Type.Array(Type.Object({
186
+ code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
187
+ file: Type.Optional(Type.String({ minLength: 1 })),
188
+ data: Type.Optional(Type.Unknown()),
189
+ }, {additionalProperties:false}), {minItems:1,maxItems:32,description:"Instead of top-level code/file/data. JSON-encoded array shares the code character cap."})),
190
+ }),
158
191
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
159
192
  // so separate call/result slots cannot duplicate the lifecycle card.
160
193
  renderShell: "self",
161
194
  mergeCallAndResult: true,
162
195
  renderCall: renderSupernovaCall,
163
196
  renderResult: renderSupernovaResult,
164
- async execute(_id, params, signal, onUpdate, ctx) {
197
+ execute: async function execute(_id, params, signal, onUpdate, ctx, budget) {
198
+ if (params?.programs !== undefined) return runProgramBatch(_id,params,signal,onUpdate,ctx,config,execute);
165
199
  cancelWarmTimer();
166
200
  const runCwd = ctx?.cwd || cwd;
167
201
  const runController = new AbortController();
168
202
  const abortRun = () => runController.abort(signal?.reason);
203
+
169
204
  if (signal?.aborted) abortRun();
170
205
  else signal?.addEventListener("abort", abortRun, { once: true });
171
- const runBridge = bridge.fork({ getCwd: () => runCwd });
206
+ const runBridge = bridge.fork({ getCwd: () => runCwd, budget });
172
207
  runBridge.bindCallContext(ctx, runController.signal);
173
208
  runBridge.resetCallBudget();
174
209
 
@@ -179,61 +214,103 @@ export function registerCodeMode(pi) {
179
214
  emitProgress([]);
180
215
  const started = performance.now();
181
216
  let outcome;
217
+ const overlappedTurn = inFlight > 0 ? inFlight + 1 : 0;
218
+ inFlight += 1;
219
+
182
220
  try {
183
221
  refreshCatalog(runBridge);
184
222
  runBridge.beginSpeculation();
185
223
  outcome = await runGuestProgram({
186
224
  code: params?.code,
225
+ file: params?.file,
226
+ cwd: runCwd,
227
+ data: params?.data,
187
228
  nova: makeNovaApi(runBridge, abortRun),
188
- config: { ...config, timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
229
+ config: { ...config, maxLogLines: Math.max(0,config.maxLogLines-(budget?.logLines ?? 0)), timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
189
230
  signal: runController.signal,
190
231
  onTimeout: abortRun,
191
232
  });
192
233
  runBridge.close();
234
+
193
235
  if (outcome.ok) {
194
236
  if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
195
237
  await runBridge.commitSpeculation();
196
238
  }
197
- else runBridge.rollbackSpeculation();
239
+ else while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
198
240
  } catch (error) {
199
241
  abortRun();
200
242
  runBridge.close();
201
- runBridge.rollbackSpeculation();
243
+
244
+ while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
202
245
  outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
203
246
  } finally {
247
+ inFlight -= 1;
204
248
  runBridge.setCallListener(null);
205
249
  emitProgress.flush();
206
250
  signal?.removeEventListener("abort", abortRun);
207
251
  // Prepare one pristine worker during the model's next decision. Never
208
252
  // recycle a worker that has executed arbitrary guest JavaScript.
209
253
  cancelWarmTimer();
254
+
210
255
  if (!stopped && !runController.signal.aborted) {
211
256
  // Deliver the result before paying for another Worker constructor.
212
257
  warmTimer = setImmediate(() => {
213
258
  warmTimer = undefined;
259
+
214
260
  if (!stopped && !runController.signal.aborted) warmGuestWorker(config).catch(() => {});
215
261
  });
216
262
  warmTimer.unref?.();
217
263
  }
218
264
  }
265
+
266
+ if (budget) budget.logLines += outcome.logs?.length ?? 0;
267
+ // inFlight has dropped by now, so a non-zero value means a sibling is still
268
+ // running: report the overlap from either side so the hint does not depend on
269
+ // which invocation happened to start first.
270
+ outcome.overlappedTurn = overlappedTurn || (inFlight > 0 ? inFlight + 1 : 0);
271
+ outcome.mutations = runBridge.getMutations();
219
272
  const trace = runBridge.getTrace();
220
- const text = outcome.ok ? successText(outcome, call) : errorText(outcome, call);
273
+ const format = outcome.ok ? successText : errorText;
274
+ let text = format(outcome, call);
275
+
276
+ if (text.length > config.maxReturnChars) {
277
+ outcome.returnTruncated = true;
278
+ text = format(outcome, call);
279
+ }
280
+
221
281
  const bounded = truncateChars(text, config.maxReturnChars, "output").text;
222
282
  const visible = runBridge.ledger.dedupe(bounded, call);
223
- if (!outcome.ok) throw new Error(visible);
283
+
224
284
  const response = result(visible, {
225
285
  ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
226
286
  returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
227
- logs: outcome.logs, result: outcome.result, trace,
287
+ logs: outcome.logs, result: outcome.result, trace, mutations: outcome.mutations,
228
288
  });
289
+
229
290
  if (outcome.images?.length) response.content.push(...outcome.images);
291
+
292
+ if (!outcome.ok) {
293
+ const error = new Error(visible);
294
+ Object.defineProperty(error,"supernovaResult",{value:response});
295
+ throw error;
296
+ }
297
+
230
298
  return response;
231
299
  },
232
300
  });
233
301
 
234
- pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer(); return stopWarmGuestWorker(); });
302
+ // This is a pre-conversion observation, not a final-payload retention proof.
303
+ // With the shipping seenWindow:0 default, observe is a no-op.
304
+ pi.on("context", event => {
305
+ try { bridge.ledger.observe(event?.messages); } catch {}
306
+ });
307
+
308
+ pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer();
309
+
310
+ return stopWarmGuestWorker(); });
235
311
  pi.on("session_start", (_event, ctx) => {
236
312
  stopped = false;
313
+
237
314
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
238
315
  // A new session is a new model context: nothing has been seen yet.
239
316
  bridge.bindCallContext(ctx);
@@ -249,11 +326,13 @@ export function registerCodeMode(pi) {
249
326
  bridge.bindCallContext(ctx);
250
327
  refreshCatalog();
251
328
  const commands = ["read", "edit", "write", "bash"].filter(bridge.isCallable);
329
+
252
330
  const lines = [
253
331
  `Supernova CodeMode: ${commands.join(", ")}`,
254
332
  `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
255
333
  sessionStats(bridge.ledger.stats),
256
334
  ];
335
+
257
336
  ctx.ui.notify(lines.join("\n"), "info");
258
337
  },
259
338
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.3.1",
3
+ "version": "0.4.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" },
@@ -13,11 +13,13 @@ const NATIVE_TOOL_DEFINITIONS = [
13
13
  about: { type: "string", description: "Question or symbol: expand file bodies, or locate and open source inside a directory" },
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
+ 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." },
16
18
  } },
17
19
  },
18
20
  {
19
21
  name: "write", description: "Write UTF-8 content to a workspace file.",
20
- parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] },
22
+ parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, allowReadArtifacts: { type: "boolean", description: "Explicit opt-in for intentionally writing literal truncation-marker text" } }, required: ["path", "content"] },
21
23
  },
22
24
  {
23
25
  name: "edit", description: "Apply unique text replacements to a workspace file; returns the post-edit lines, a structural check, and references to changed declarations.",
@@ -84,28 +86,36 @@ export function mergeNativeToolDefinitions(tools, capturedNames = []) {
84
86
  merged.push(fallback && !captured.has(tool.name)
85
87
  ? { ...tool, ...fallback, sourceInfo: { path: "<native:" + tool.name + ">" } }
86
88
  : tool);
89
+
87
90
  if (tool?.name) seen.add(tool.name);
88
91
  }
92
+
89
93
  for (const fallback of NATIVE_TOOL_DEFINITIONS) {
90
94
  if (!seen.has(fallback.name)) {
91
95
  merged.push({ ...fallback, sourceInfo: { path: "<native:" + fallback.name + ">" } });
92
96
  }
93
97
  }
98
+
94
99
  return merged;
95
100
  }
96
101
 
97
102
  function sourcePathOf(tool) {
98
103
  if (tool.sourceInfo && isString(tool.sourceInfo.path)) return tool.sourceInfo.path;
104
+
99
105
  if (isString(tool.extensionPath)) return tool.extensionPath;
106
+
100
107
  if (isString(tool.sourcePath)) return tool.sourcePath;
108
+
101
109
  return undefined;
102
110
  }
103
111
 
104
112
  function normalizeTool(tool) {
105
113
  if (!tool || !isObject(tool)) return null;
106
114
  const name = isString(tool.name) ? tool.name : "";
115
+
107
116
  if (!name) return null;
108
117
  const description = isString(tool.description) ? tool.description : "";
118
+
109
119
  return {
110
120
  name,
111
121
  nameLower: name.toLowerCase(),
@@ -120,12 +130,16 @@ function normalizeTool(tool) {
120
130
  export function buildCatalog(tools, excludeNames = []) {
121
131
  const exclude = new Set(excludeNames);
122
132
  const rows = [];
133
+
123
134
  for (const tool of tools || []) {
124
135
  const row = normalizeTool(tool);
136
+
125
137
  if (!row || exclude.has(row.name)) continue;
126
138
  rows.push(row);
127
139
  }
140
+
128
141
  rows.sort((a, b) => a.name.localeCompare(b.name));
142
+
129
143
  return rows;
130
144
  }
131
145
 
@@ -141,74 +155,94 @@ function scoreRow(row, tokens) {
141
155
  const name = row.nameLower || row.name.toLowerCase();
142
156
  const desc = row.descLower || row.description.toLowerCase();
143
157
  let score = 0;
158
+
144
159
  for (const token of tokens) {
145
160
  if (name === token) score += 10;
146
161
  else if (name.includes(token)) score += 5;
147
162
  else if (desc.includes(token)) score += 2;
148
163
  }
164
+
149
165
  return score;
150
166
  }
151
167
 
152
168
  export function searchCatalog(catalog, query, limit = 12) {
153
169
  const tokens = tokenize(query);
154
170
  const scored = [];
171
+
155
172
  for (const row of catalog) {
156
173
  const score = scoreRow(row, tokens);
174
+
157
175
  if (score <= 0 && tokens.length > 0) continue;
158
176
  scored.push({ name: row.name, description: row.description.slice(0, 160), score });
159
177
  }
178
+
160
179
  scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
180
+
161
181
  return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
162
182
  }
163
183
 
164
184
  /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
165
185
  function editDistance(a, b) {
166
186
  const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
187
+
167
188
  for (let j = 1; j <= b.length; j++) rows[0][j] = j;
189
+
168
190
  for (let i = 1; i <= a.length; i++) {
169
191
  for (let j = 1; j <= b.length; j++) {
170
192
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
171
193
  let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
194
+
172
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);
173
196
  rows[i][j] = best;
174
197
  }
175
198
  }
199
+
176
200
  return rows[a.length][b.length];
177
201
  }
178
202
 
179
203
  /** Closest tool names for a mistyped name: substring hits first, then a length-scaled edit distance. */
180
204
  function suggestNames(name, candidates, limit = 3) {
181
205
  const needle = String(name || "").toLowerCase();
206
+
182
207
  if (!needle) return [];
183
208
  const maxDistance = Math.max(1, Math.floor(needle.length / 3));
184
209
  const scored = [];
210
+
185
211
  for (const candidate of candidates) {
186
212
  const lower = candidate.toLowerCase();
213
+
187
214
  if (lower === needle) continue;
188
215
  const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
216
+
189
217
  if (distance <= maxDistance) scored.push({ candidate, distance });
190
218
  }
219
+
191
220
  scored.sort(
192
221
  (a, b) =>
193
222
  a.distance - b.distance ||
194
223
  Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
195
224
  a.candidate.localeCompare(b.candidate),
196
225
  );
226
+
197
227
  return scored.slice(0, limit).map((s) => s.candidate);
198
228
  }
199
229
 
200
230
  export function unknownToolMessage(name, candidates) {
201
231
  const close = suggestNames(name, candidates);
202
232
  const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
233
+
203
234
  return `unknown tool "${name}".${hint} Check the command name and configured tool exclusions.`;
204
235
  }
205
236
 
206
237
  export function describeTool(catalog, name) {
207
238
  const row = catalog.find((t) => t.name === name);
239
+
208
240
  if (!row) {
209
241
  return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
210
242
  }
243
+
211
244
  if (!isObject(row.parameters)) return { ok: false, name, error: "tool schema unavailable: " + (row.schemaError ?? name) };
245
+
212
246
  if (!row._described) {
213
247
  row._described = {
214
248
  ok: true,
@@ -218,5 +252,6 @@ export function describeTool(catalog, name) {
218
252
  sourcePath: row.sourcePath,
219
253
  };
220
254
  }
255
+
221
256
  return row._described;
222
257
  }