pi-supernova 0.1.0 → 0.3.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.
Files changed (33) hide show
  1. package/README.md +196 -173
  2. package/{CHANGELOG.md → docs/CHANGELOG.md} +57 -1
  3. package/index.js +88 -75
  4. package/package.json +12 -31
  5. package/{catalog.js → src/bridge/catalog.js} +9 -7
  6. package/{host-bridge.js → src/bridge/host-bridge.js} +280 -123
  7. package/src/bridge/native-tools.js +155 -0
  8. package/src/bridge/pi-extension.ts +2 -0
  9. package/{config.js → src/config/config.js} +1 -1
  10. package/{evidence.js → src/context/evidence.js} +23 -12
  11. package/{outline.js → src/context/outline.js} +1 -1
  12. package/{repo-index.js → src/context/repo-index.js} +25 -15
  13. package/{search.js → src/context/search.js} +34 -1
  14. package/src/context/snap.js +237 -0
  15. package/{surface.js → src/context/surface.js} +1 -1
  16. package/{diff.js → src/fs/diff.js} +7 -5
  17. package/{patch.js → src/fs/patch.js} +1 -1
  18. package/{vfs.js → src/fs/vfs.js} +55 -14
  19. package/{workspace.js → src/fs/workspace.js} +22 -6
  20. package/{bottleneck.js → src/output/bottleneck.js} +23 -6
  21. package/{format.js → src/output/format.js} +20 -1
  22. package/{guest-worker.js → src/runtime/guest-worker.js} +90 -21
  23. package/{parallel.js → src/runtime/parallel.js} +68 -1
  24. package/{runtime.js → src/runtime/runtime.js} +14 -5
  25. package/{omp-frame.js → src/ui/omp-frame.js} +1 -1
  26. package/{render-measure.js → src/ui/render-measure.js} +27 -1
  27. package/{render.js → src/ui/render.js} +42 -20
  28. package/snap.js +0 -248
  29. /package/{config.default.json → src/config/config.default.json} +0 -0
  30. /package/{fuzzy.js → src/context/fuzzy.js} +0 -0
  31. /package/{ledger.js → src/context/ledger.js} +0 -0
  32. /package/{check.js → src/fs/check.js} +0 -0
  33. /package/{decode.js → src/shared/decode.js} +0 -0
package/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
- import { isString, isFunction } from "./decode.js";
3
- import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
4
- import { loadConfig } from "./config.js";
5
- import { createHostBridge } from "./host-bridge.js";
6
- import { runGuestProgram, warmGuestWorker } from "./runtime.js";
7
- import { renderSupernovaCall, renderSupernovaResult } from "./render.js";
2
+ import { isString, isFunction } from "./src/shared/decode.js";
3
+ import { loadConfig } from "./src/config/config.js";
4
+ import { createHostBridge } from "./src/bridge/host-bridge.js";
5
+ import { truncateChars } from "./src/output/format.js";
6
+ import { runGuestProgram, warmGuestWorker, stopWarmGuestWorker } from "./src/runtime/runtime.js";
7
+ import { renderSupernovaCall, renderSupernovaResult } from "./src/ui/render.js";
8
8
 
9
9
  export { renderSupernovaCall, renderSupernovaResult };
10
10
 
@@ -26,37 +26,36 @@ function result(text, details) {
26
26
  return { content: [{ type: "text", text }], details };
27
27
  }
28
28
 
29
- const PROGRESS_FRAME_MS = 40;
29
+ const PROGRESS_FRAME_MS = 80;
30
30
 
31
31
  /**
32
32
  * Live trace updates for the card. The first update is immediate (seeds the result slot);
33
33
  * later ones are coalesced to one host re-render per frame so a tight loop of nova.calls
34
34
  * is not throttled by the TUI. A throwing host callback must never break the run.
35
35
  */
36
- function progressEmitter(onUpdate) {
36
+ export function progressEmitter(onUpdate) {
37
37
  if (!isFunction(onUpdate)) return Object.assign(() => {}, { flush() {} });
38
38
  let pending = null;
39
39
  let timer = null;
40
- const send = (trace) => {
41
- try {
42
- onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
43
- } catch {}
44
- };
45
- const flush = () => {
40
+ let lastSent = -Infinity;
41
+ const send = () => {
46
42
  timer = null;
47
43
  if (pending === null) return;
48
- const trace = pending;
44
+ // Snapshot only at emission, not on every tool event. Completed records must
45
+ // not mutate a previously emitted frame while Pi is still consuming it.
46
+ const trace = pending.map(record => ({ ...record }));
49
47
  pending = null;
50
- send(trace);
48
+ lastSent = performance.now();
49
+ try {
50
+ onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
51
+ } catch {}
51
52
  };
52
53
  const emit = (trace) => {
53
- if (timer === null && pending === null) {
54
- send(trace);
55
- timer = setTimeout(flush, PROGRESS_FRAME_MS);
56
- return;
57
- }
58
54
  pending = trace;
59
- if (timer === null) timer = setTimeout(flush, PROGRESS_FRAME_MS);
55
+ if (timer !== null) return;
56
+ const wait = PROGRESS_FRAME_MS - (performance.now() - lastSent);
57
+ if (wait <= 0) send();
58
+ else timer = setTimeout(send, wait);
60
59
  };
61
60
  emit.flush = () => {
62
61
  if (timer !== null) clearTimeout(timer);
@@ -66,10 +65,8 @@ function progressEmitter(onUpdate) {
66
65
  return emit;
67
66
  }
68
67
 
69
- function sessionStats({ programs, returnedChars, collapsedChars, collapsedRuns }) {
70
- const total = returnedChars + collapsedChars;
71
- const pct = total ? Math.round((collapsedChars / total) * 100) : 0;
72
- return `this session: ${programs} programs · ~${Math.round(returnedChars / 4)} tokens returned · ~${Math.round(collapsedChars / 4)} already-seen tokens not re-sent (${pct}%, ${collapsedRuns} runs)`;
68
+ function sessionStats({ programs, returnedChars }) {
69
+ return `this session: ${programs} programs · ${returnedChars} output characters (not token counts)`;
73
70
  }
74
71
 
75
72
  function logsBlock(outcome, tail = "") {
@@ -85,25 +82,40 @@ function successText(outcome, call) {
85
82
  const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
86
83
  return `ok #${call} ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
87
84
  }
88
- const TOOL_DESCRIPTION = `Run one JavaScript program that composes host tools. Async body or arrow; \`return\` a small shaped value (compact literal, capped; strings raw; console.log is captured).
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.
89
86
 
90
- Globals (async):
91
- read(path|paths, offset?, limit?) → text | text[] · read(path, {about}) → whole-file outline, only relevant bodies expanded
92
- write(path, text) · edit(path, oldText, newText) → post-edit lines (no re-read needed) · patch(path, unifiedDiff)
93
- bash(cmd, {cwd?, timeoutMs?}) → output, throws on non-zero exit · exec(cmd, argv?) quotes argv
94
- evidence(query, {k?}) → {spans: [{path, lines, name, text}]} top-K spans that answer a question; use before read
95
- snap(query, root?) → {path, line, signature, context} · surface(path) → {items: [{name, kind, line}]}
96
- nova.call(name, args) → {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
97
- nova.search(query) → [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
98
- parallel(thunks) · pipeline(items, ...stages)
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
99
98
 
100
- Already-seen lines collapse to "⋯ N lines same as #12 · path:a–b ⋯"; read(path, a, n) re-shows them.`;
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.`;
101
102
 
102
103
  export default function piSupernova(pi) {
103
- const config = loadConfig();
104
+ registerCodeMode(pi);
105
+ }
106
+
107
+ // Shared entry used by both host adapters and direct engine integration.
108
+ export function registerCodeMode(pi) {
109
+ // Local cache residency cannot establish what remains in the model's context.
110
+ const config = { ...loadConfig(), seenWindow: 0 };
104
111
  let cwd = process.cwd();
105
- let catalog = [];
106
112
  let programSeq = 0;
113
+ let stopped = false;
114
+ let warmTimer;
115
+ function cancelWarmTimer() {
116
+ if (warmTimer !== undefined) clearImmediate(warmTimer);
117
+ warmTimer = undefined;
118
+ }
107
119
 
108
120
  const bridge = createHostBridge({
109
121
  pi,
@@ -112,36 +124,21 @@ export default function piSupernova(pi) {
112
124
  });
113
125
 
114
126
  function refreshCatalog(target = bridge) {
115
- const tools = target.refreshTools();
116
- const discoverable = mergeNativeToolDefinitions(tools, target.externalNames())
117
- .filter(tool => target.isCallable(tool.name))
118
- .map(tool => {
119
- const schema = tool.parameters;
120
- try {
121
- if (isFunction(schema?.toJsonSchema)) return { ...tool, parameters: schema.toJsonSchema() };
122
- if (schema && !schema.type && pi.zod?.toJSONSchema && (schema._zod || schema._def)) {
123
- return { ...tool, parameters: pi.zod.toJSONSchema(schema, { io: "input" }) };
124
- }
125
- return tool;
126
- } catch (error) {
127
- return { ...tool, parameters: undefined, schemaError: error.message };
128
- }
129
- });
130
- catalog = buildCatalog(discoverable, config.excludeTools || []);
131
- return catalog;
127
+ // Refresh executors and permissions, not a model-facing catalogue. Guest
128
+ // programs cannot dispatch arbitrary tools or consume their schemas.
129
+ return target.refreshTools();
132
130
  }
133
131
 
134
- function makeNovaApi(runBridge, runCatalog, cancel) {
132
+ function makeNovaApi(runBridge, cancel) {
135
133
  return {
136
- search: async (query, limit) => searchCatalog(runCatalog, query, Number.isInteger(limit) ? limit : config.maxSearchResults),
137
- describe: async (name) => describeTool(runCatalog, name),
138
134
  call: (name, args) => runBridge.call(name, args),
139
135
  callMany: (calls) => runBridge.callMany(calls),
140
- speculateBegin: () => runBridge.beginSpeculation(),
141
- speculateCommit: () => runBridge.commitSpeculation(),
142
- speculateRollback: () => runBridge.rollbackSpeculation(),
143
- names: () => runCatalog.map(tool => tool.name),
136
+ speculateBegin: () => runBridge.barrier(() => runBridge.beginSpeculation()),
137
+ speculateCommit: () => runBridge.barrier(() => runBridge.commitSpeculation()),
138
+ speculateRollback: () => runBridge.barrier(() => runBridge.rollbackSpeculation()),
139
+ names: () => ["read", "edit", "write", "bash"],
144
140
  batchRead: runBridge.supportsBatchRead(),
141
+ nativeArgv: runBridge.supportsNativeArgv?.() === true,
145
142
  cancel,
146
143
  };
147
144
  }
@@ -150,14 +147,14 @@ export default function piSupernova(pi) {
150
147
  name: "supernova",
151
148
  label: "Supernova",
152
149
  description: TOOL_DESCRIPTION,
153
- promptSnippet: "Compose host tools in one JavaScript program",
150
+ promptSnippet: "Use read, write, edit, and bash in one program",
154
151
  promptGuidelines: [
155
- "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. To understand code: evidence(question) across the repo, or read(path, {about: question}) for one file: full structure, only relevant bodies expanded. Plain read(path) only for lines you will edit. Return a compact shaped value; keep raw tool output inside the program.",
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.",
156
153
  ],
157
154
  parameters: Type.Object({
158
155
  code: Type.String({ description: "JavaScript program: async body or arrow function." }),
159
156
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
160
- }),
157
+ }, { required: ["code"] }),
161
158
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
162
159
  // so separate call/result slots cannot duplicate the lifecycle card.
163
160
  renderShell: "self",
@@ -165,6 +162,7 @@ export default function piSupernova(pi) {
165
162
  renderCall: renderSupernovaCall,
166
163
  renderResult: renderSupernovaResult,
167
164
  async execute(_id, params, signal, onUpdate, ctx) {
165
+ cancelWarmTimer();
168
166
  const runCwd = ctx?.cwd || cwd;
169
167
  const runController = new AbortController();
170
168
  const abortRun = () => runController.abort(signal?.reason);
@@ -182,18 +180,18 @@ export default function piSupernova(pi) {
182
180
  const started = performance.now();
183
181
  let outcome;
184
182
  try {
185
- const runCatalog = refreshCatalog(runBridge);
183
+ refreshCatalog(runBridge);
186
184
  runBridge.beginSpeculation();
187
185
  outcome = await runGuestProgram({
188
186
  code: params?.code,
189
- nova: makeNovaApi(runBridge, runCatalog, abortRun),
187
+ nova: makeNovaApi(runBridge, abortRun),
190
188
  config: { ...config, timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
191
189
  signal: runController.signal,
192
190
  onTimeout: abortRun,
193
191
  });
194
192
  runBridge.close();
195
193
  if (outcome.ok) {
196
- if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished nova.speculate branch; await it before returning");
194
+ if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
197
195
  await runBridge.commitSpeculation();
198
196
  }
199
197
  else runBridge.rollbackSpeculation();
@@ -206,18 +204,36 @@ export default function piSupernova(pi) {
206
204
  runBridge.setCallListener(null);
207
205
  emitProgress.flush();
208
206
  signal?.removeEventListener("abort", abortRun);
207
+ // Prepare one pristine worker during the model's next decision. Never
208
+ // recycle a worker that has executed arbitrary guest JavaScript.
209
+ cancelWarmTimer();
210
+ if (!stopped && !runController.signal.aborted) {
211
+ // Deliver the result before paying for another Worker constructor.
212
+ warmTimer = setImmediate(() => {
213
+ warmTimer = undefined;
214
+ if (!stopped && !runController.signal.aborted) warmGuestWorker(config).catch(() => {});
215
+ });
216
+ warmTimer.unref?.();
217
+ }
209
218
  }
210
219
  const trace = runBridge.getTrace();
211
220
  const text = outcome.ok ? successText(outcome, call) : errorText(outcome, call);
212
- return result(runBridge.ledger.dedupe(text, call), {
221
+ const bounded = truncateChars(text, config.maxReturnChars, "output").text;
222
+ const visible = runBridge.ledger.dedupe(bounded, call);
223
+ if (!outcome.ok) throw new Error(visible);
224
+ const response = result(visible, {
213
225
  ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
214
226
  returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
215
227
  logs: outcome.logs, result: outcome.result, trace,
216
228
  });
229
+ if (outcome.images?.length) response.content.push(...outcome.images);
230
+ return response;
217
231
  },
218
232
  });
219
233
 
234
+ pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer(); return stopWarmGuestWorker(); });
220
235
  pi.on("session_start", (_event, ctx) => {
236
+ stopped = false;
221
237
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
222
238
  // A new session is a new model context: nothing has been seen yet.
223
239
  bridge.bindCallContext(ctx);
@@ -232,12 +248,9 @@ export default function piSupernova(pi) {
232
248
  handler: async (_args, ctx) => {
233
249
  bridge.bindCallContext(ctx);
234
250
  refreshCatalog();
235
- const external = bridge.externalNames().filter(bridge.isCallable).sort();
236
- const natives = Object.keys(bridge.natives).filter(name => bridge.isCallable(name) && !external.includes(name)).sort();
251
+ const commands = ["read", "edit", "write", "bash"].filter(bridge.isCallable);
237
252
  const lines = [
238
- `pi-supernova catalog: ${catalog.length} tools`,
239
- `external tools: ${external.length ? external.join(", ") : "(none)"}`,
240
- `native adapters: ${natives.join(", ")}`,
253
+ `Supernova CodeMode: ${commands.join(", ")}`,
241
254
  `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
242
255
  sessionStats(bridge.ledger.stats),
243
256
  ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.1.0",
4
- "description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
3
+ "version": "0.3.0",
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",
7
7
  "license": "MIT",
@@ -24,38 +24,15 @@
24
24
  "exports": "./index.js",
25
25
  "files": [
26
26
  "index.js",
27
- "catalog.js",
28
- "host-bridge.js",
29
- "bottleneck.js",
30
- "parallel.js",
31
- "runtime.js",
32
- "guest-worker.js",
33
- "repo-index.js",
34
- "evidence.js",
35
- "fuzzy.js",
36
- "outline.js",
37
- "ledger.js",
38
- "check.js",
39
- "search.js",
40
- "format.js",
41
- "patch.js",
42
- "vfs.js",
43
- "workspace.js",
44
- "config.js",
45
- "config.default.json",
27
+ "src/",
28
+ "docs/",
46
29
  "README.md",
47
- "LICENSE",
48
- "CHANGELOG.md",
49
- "snap.js",
50
- "diff.js",
51
- "render.js",
52
- "render-measure.js",
53
- "omp-frame.js",
54
- "surface.js",
55
- "decode.js"
30
+ "LICENSE"
56
31
  ],
57
32
  "scripts": {
58
- "test": "node --test test/*.test.mjs",
33
+ "test": "node --test tests/*/*.test.mjs",
34
+ "test:hosts": "node tests/hosts/verify.mjs",
35
+ "measure": "node tests/efficiency/measure.mjs",
59
36
  "prepublishOnly": "npm test && node ../../scripts/preflight.mjs"
60
37
  },
61
38
  "pi": {
@@ -69,9 +46,13 @@
69
46
  ]
70
47
  },
71
48
  "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*",
72
50
  "typebox": "*"
73
51
  },
74
52
  "peerDependenciesMeta": {
53
+ "@earendil-works/pi-coding-agent": {
54
+ "optional": true
55
+ },
75
56
  "typebox": {
76
57
  "optional": true
77
58
  }
@@ -1,16 +1,18 @@
1
1
 
2
- import { isString, isObject } from "./decode.js";
2
+ import { isString, isObject } from "../shared/decode.js";
3
3
 
4
4
  const NATIVE_TOOL_DEFINITIONS = [
5
5
  {
6
6
  name: "read",
7
- description: "Read UTF-8 workspace files by path, or resolve a concept query to source. Supports path arrays, offset, and limit.",
7
+ description: "Read files or directories. Source questions locate and open source directly; resolve returns structured source/status without guessing.",
8
8
  parameters: { type: "object", properties: {
9
- path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Workspace-relative file path, concept query, or array of paths" },
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" },
11
11
  offset: { type: "number", description: "One-based starting line" },
12
12
  limit: { type: "number", description: "Maximum lines to return" },
13
- about: { type: "string", description: "Question or symbol: returns the whole file as an outline with only the relevant bodies expanded" },
13
+ about: { type: "string", description: "Question or symbol: expand file bodies, or locate and open source inside a directory" },
14
+ query: { type: "string", description: "Source question; optional path scopes the search directory" },
15
+ resolve: { type: "boolean", description: "Return structured source/status for a direct resolve-to-edit handoff" },
14
16
  } },
15
17
  },
16
18
  {
@@ -28,7 +30,7 @@ const NATIVE_TOOL_DEFINITIONS = [
28
30
  parameters: { type: "object", properties: { path: { type: "string" }, patch: { type: "string" } }, required: ["patch"] },
29
31
  },
30
32
  {
31
- name: "snap", description: "Resolve a concept query to the most relevant workspace source location.",
33
+ name: "snap", description: "Select source with found, ambiguous, not_found, or incomplete status. The read command uses the same engine.",
32
34
  parameters: { type: "object", properties: {
33
35
  query: { type: "string", description: "Source concept to resolve" },
34
36
  path: { type: "string", description: "Optional workspace search root; explicitly targeting a hidden directory includes its hidden files, but Git metadata is always excluded" },
@@ -161,7 +163,7 @@ export function searchCatalog(catalog, query, limit = 12) {
161
163
 
162
164
  /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
163
165
  function editDistance(a, b) {
164
- const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]);
166
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
165
167
  for (let j = 1; j <= b.length; j++) rows[0][j] = j;
166
168
  for (let i = 1; i <= a.length; i++) {
167
169
  for (let j = 1; j <= b.length; j++) {
@@ -198,7 +200,7 @@ function suggestNames(name, candidates, limit = 3) {
198
200
  export function unknownToolMessage(name, candidates) {
199
201
  const close = suggestNames(name, candidates);
200
202
  const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
201
- return `unknown tool "${name}".${hint} Use nova.search("") to list every callable tool.`;
203
+ return `unknown tool "${name}".${hint} Check the command name and configured tool exclusions.`;
202
204
  }
203
205
 
204
206
  export function describeTool(catalog, name) {