pi-supernova 0.3.2 → 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,36 +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; 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.`;
121
+
122
+ const TOOL_DESCRIPTION = REFERENCE;
103
123
 
104
124
  export default function piSupernova(pi) {
105
125
  registerCodeMode(pi);
@@ -107,12 +127,20 @@ export default function piSupernova(pi) {
107
127
 
108
128
  // Shared entry used by both host adapters and direct engine integration.
109
129
  export function registerCodeMode(pi) {
110
- // Local cache residency cannot establish what remains in the model's context.
111
- 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();
112
134
  let cwd = process.cwd();
113
135
  let programSeq = 0;
114
136
  let stopped = false;
115
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
+
116
144
  function cancelWarmTimer() {
117
145
  if (warmTimer !== undefined) clearImmediate(warmTimer);
118
146
  warmTimer = undefined;
@@ -148,28 +176,34 @@ export function registerCodeMode(pi) {
148
176
  name: "supernova",
149
177
  label: "Supernova",
150
178
  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
- ],
179
+ promptSnippet: "JavaScript with read, write, edit, and bash",
155
180
  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.` }),
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." })),
157
184
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
158
- }, { 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
+ }),
159
191
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
160
192
  // so separate call/result slots cannot duplicate the lifecycle card.
161
193
  renderShell: "self",
162
194
  mergeCallAndResult: true,
163
195
  renderCall: renderSupernovaCall,
164
196
  renderResult: renderSupernovaResult,
165
- 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);
166
199
  cancelWarmTimer();
167
200
  const runCwd = ctx?.cwd || cwd;
168
201
  const runController = new AbortController();
169
202
  const abortRun = () => runController.abort(signal?.reason);
203
+
170
204
  if (signal?.aborted) abortRun();
171
205
  else signal?.addEventListener("abort", abortRun, { once: true });
172
- const runBridge = bridge.fork({ getCwd: () => runCwd });
206
+ const runBridge = bridge.fork({ getCwd: () => runCwd, budget });
173
207
  runBridge.bindCallContext(ctx, runController.signal);
174
208
  runBridge.resetCallBudget();
175
209
 
@@ -180,61 +214,103 @@ export function registerCodeMode(pi) {
180
214
  emitProgress([]);
181
215
  const started = performance.now();
182
216
  let outcome;
217
+ const overlappedTurn = inFlight > 0 ? inFlight + 1 : 0;
218
+ inFlight += 1;
219
+
183
220
  try {
184
221
  refreshCatalog(runBridge);
185
222
  runBridge.beginSpeculation();
186
223
  outcome = await runGuestProgram({
187
224
  code: params?.code,
225
+ file: params?.file,
226
+ cwd: runCwd,
227
+ data: params?.data,
188
228
  nova: makeNovaApi(runBridge, abortRun),
189
- 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 },
190
230
  signal: runController.signal,
191
231
  onTimeout: abortRun,
192
232
  });
193
233
  runBridge.close();
234
+
194
235
  if (outcome.ok) {
195
236
  if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
196
237
  await runBridge.commitSpeculation();
197
238
  }
198
- else runBridge.rollbackSpeculation();
239
+ else while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
199
240
  } catch (error) {
200
241
  abortRun();
201
242
  runBridge.close();
202
- runBridge.rollbackSpeculation();
243
+
244
+ while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
203
245
  outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
204
246
  } finally {
247
+ inFlight -= 1;
205
248
  runBridge.setCallListener(null);
206
249
  emitProgress.flush();
207
250
  signal?.removeEventListener("abort", abortRun);
208
251
  // Prepare one pristine worker during the model's next decision. Never
209
252
  // recycle a worker that has executed arbitrary guest JavaScript.
210
253
  cancelWarmTimer();
254
+
211
255
  if (!stopped && !runController.signal.aborted) {
212
256
  // Deliver the result before paying for another Worker constructor.
213
257
  warmTimer = setImmediate(() => {
214
258
  warmTimer = undefined;
259
+
215
260
  if (!stopped && !runController.signal.aborted) warmGuestWorker(config).catch(() => {});
216
261
  });
217
262
  warmTimer.unref?.();
218
263
  }
219
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();
220
272
  const trace = runBridge.getTrace();
221
- 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
+
222
281
  const bounded = truncateChars(text, config.maxReturnChars, "output").text;
223
282
  const visible = runBridge.ledger.dedupe(bounded, call);
224
- if (!outcome.ok) throw new Error(visible);
283
+
225
284
  const response = result(visible, {
226
285
  ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
227
286
  returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
228
- logs: outcome.logs, result: outcome.result, trace,
287
+ logs: outcome.logs, result: outcome.result, trace, mutations: outcome.mutations,
229
288
  });
289
+
230
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
+
231
298
  return response;
232
299
  },
233
300
  });
234
301
 
235
- 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(); });
236
311
  pi.on("session_start", (_event, ctx) => {
237
312
  stopped = false;
313
+
238
314
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
239
315
  // A new session is a new model context: nothing has been seen yet.
240
316
  bridge.bindCallContext(ctx);
@@ -250,11 +326,13 @@ export function registerCodeMode(pi) {
250
326
  bridge.bindCallContext(ctx);
251
327
  refreshCatalog();
252
328
  const commands = ["read", "edit", "write", "bash"].filter(bridge.isCallable);
329
+
253
330
  const lines = [
254
331
  `Supernova CodeMode: ${commands.join(", ")}`,
255
332
  `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
256
333
  sessionStats(bridge.ledger.stats),
257
334
  ];
335
+
258
336
  ctx.ui.notify(lines.join("\n"), "info");
259
337
  },
260
338
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.3.2",
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" },
@@ -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
  }