pi-supernova 0.0.15 → 0.2.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/host-bridge.js CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { packageHostResult } from "./bottleneck.js";
4
+ import { packageHostResult, hostResultFailed } from "./bottleneck.js";
5
5
  import { isString, isNumber, isFunction, isObject } from "./decode.js";
6
6
  import { isMutatingTool, runParallelWave } from "./parallel.js";
7
7
  import { unknownToolMessage } from "./catalog.js";
@@ -55,15 +55,15 @@ function looksLikePath(target) {
55
55
  );
56
56
  }
57
57
 
58
- async function probeExistingFile(cwd, targetParam, vfs) {
59
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
60
- if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return targetPath;
58
+ async function probeExistingPath(cwd, targetParam, vfs) {
59
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", true);
60
+ if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return { path: targetPath, directory: false };
61
61
  try {
62
62
  const st = await fs.stat(targetPath);
63
- if (st.isDirectory()) throw new Error(`read path is a directory, not a file: ${targetPath} (use ls)`);
64
- return targetPath;
63
+ return { path: targetPath, directory: st.isDirectory() };
65
64
  } catch (err) {
66
65
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
66
+ if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
67
67
  return null;
68
68
  }
69
69
  }
@@ -79,7 +79,7 @@ function applyReplacements(target, content, requestedEdits) {
79
79
  if (index < 0) {
80
80
  throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
81
81
  }
82
- if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
82
+ if (content.indexOf(replacement.oldText, index + 1) >= 0) {
83
83
  throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
84
84
  }
85
85
  return { ...replacement, index, end: index + replacement.oldText.length };
@@ -96,6 +96,11 @@ function applyReplacements(target, content, requestedEdits) {
96
96
  return { updated, matches };
97
97
  }
98
98
 
99
+ function formatDirectoryEntry(name, type, size = 0) {
100
+ const sizeSuffix = size ? `, ${size} bytes` : "";
101
+ return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
102
+ }
103
+
99
104
  async function formatLsEntry(dirPath, entry) {
100
105
  const isDir = entry.isDirectory();
101
106
  const isSym = entry.isSymbolicLink();
@@ -107,46 +112,55 @@ async function formatLsEntry(dirPath, entry) {
107
112
  size = st.size;
108
113
  }
109
114
  } catch {}
110
- const sizeSuffix = size ? `, ${size} bytes` : "";
111
- return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
115
+ return formatDirectoryEntry(entry.name, typeLabel, size);
112
116
  }
113
117
 
114
118
  function createNativeAdapters(getCwd, vfs, config, index, ledger) {
115
- async function readAdapter(params, signal) {
116
- const cwd = getCwd();
117
- const targetParam = params?.path ?? params?.target;
118
-
119
- if (Array.isArray(targetParam)) {
120
- const results = await Promise.all(
121
- targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
122
- );
123
- const items = results.map((r) => r.content[0].text);
124
- return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
125
- }
126
-
127
- if (looksLikePath(targetParam)) {
128
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
129
- return readFile(targetPath, params);
130
- }
131
-
132
- const existing = await probeExistingFile(cwd, targetParam, vfs);
133
- if (existing) return readFile(existing, params);
119
+ async function sourceRead(query, searchDir, signal) {
120
+ const cwd = getCwd();
121
+ const includeHidden = path.relative(cwd, searchDir).split(path.sep)
122
+ .some(segment => segment.startsWith(".") && segment.length > 1);
123
+ const result = await executeSnap({ query, searchDir, root: cwd, includeHidden, index,
124
+ overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
125
+ return textResult(JSON.stringify(result, null, 2), { ...result, isSnap: true });
126
+ }
134
127
 
135
- if (isString(targetParam) && targetParam.trim()) {
136
- try {
137
- const snapRes = await executeSnap({
138
- query: targetParam,
139
- searchDir: cwd,
140
- index,
141
- overlayText: (p) => vfs.getOverlay(p),
142
- pendingPaths: vfs.getOverlayPaths(),
143
- });
144
- return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
145
- } catch {}
146
- }
128
+ async function readDirectory(dirPath, signal) {
129
+ signal?.throwIfAborted();
130
+ const rows = new Map();
131
+ for (const file of vfs.getOverlayPaths()) {
132
+ const relative = path.relative(dirPath, file);
133
+ if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
134
+ const [name, child] = relative.split(path.sep);
135
+ rows.set(name, child === undefined
136
+ ? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
137
+ : formatDirectoryEntry(name, "dir"));
138
+ }
139
+ let entries;
140
+ try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
141
+ if (error.code !== "ENOENT" || rows.size === 0) throw error;
142
+ entries = [];
143
+ }
144
+ for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
145
+ return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
146
+ }
147
147
 
148
- const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
149
- return readFile(targetPath, params);
148
+ async function readAdapter(params, signal) {
149
+ signal?.throwIfAborted();
150
+ const cwd = getCwd();
151
+ const targetParam = params?.path ?? params?.target;
152
+ if (Array.isArray(targetParam)) {
153
+ const results = await Promise.all(targetParam.map(p => readAdapter({ ...params, path: p }, signal)));
154
+ return textResult("", { count: results.length, batch: true, items: results.map(r => r.content[0].text) });
155
+ }
156
+ const existing = await probeExistingPath(cwd, targetParam, vfs);
157
+ if (existing) {
158
+ if (!existing.directory) return readFile(existing.path, params);
159
+ return isString(params?.about) ? sourceRead(params.about, existing.path, signal) : readDirectory(existing.path, signal);
160
+ }
161
+ if (!looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal);
162
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
163
+ return readFile(targetPath, params);
150
164
  }
151
165
 
152
166
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
@@ -281,7 +295,8 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
281
295
  const cwd = getCwd();
282
296
  const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
283
297
  if (signal?.aborted) throw new Error("aborted");
284
- const content = String(params?.content ?? "");
298
+ if (!isString(params?.content)) throw new Error("write requires string content");
299
+ const content = params.content;
285
300
  let prevText = "";
286
301
  try {
287
302
  prevText = await vfs.read(target);
@@ -308,7 +323,6 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
308
323
  matches.length === 1
309
324
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
310
325
  : buildMultiEditDiff(target, content, matches);
311
- const tag = speculative ? " (speculative)" : "";
312
326
  const summary = await editSummary(cwd, target, content, updated, diff);
313
327
  return textResult(summary, { path: target, speculative, diff });
314
328
  },
@@ -356,6 +370,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
356
370
  index,
357
371
  overlayText: (p) => vfs.getOverlay(p),
358
372
  pendingPaths: vfs.getOverlayPaths(),
373
+ signal,
359
374
  });
360
375
  return textResult(JSON.stringify(res, null, 2), res);
361
376
  },
@@ -367,7 +382,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
367
382
  const options = {};
368
383
  if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
369
384
  if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
370
- const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), options });
385
+ const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
371
386
  for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
372
387
  return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
373
388
  },
@@ -408,7 +423,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
408
423
  if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
409
424
  return {
410
425
  content: [{ type: "text", text }],
411
- details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
426
+ details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
412
427
  isError: res.exitCode !== 0,
413
428
  };
414
429
  },
@@ -463,30 +478,29 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
463
478
  async ls(params, signal) {
464
479
  const cwd = getCwd();
465
480
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
466
- if (signal?.aborted) throw new Error("aborted");
467
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
468
- const lines = [];
469
- for (const entry of entries) {
470
- lines.push(await formatLsEntry(dirPath, entry));
471
- }
472
- return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
481
+ return readDirectory(dirPath, signal);
473
482
  },
474
483
  };
475
484
  }
476
485
 
477
- export function createHostBridge({ pi, config, getCwd }) {
478
- const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
479
- const ledger = new SeenLedger({ window: config.seenWindow ?? 40 });
486
+ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
487
+ const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
488
+ const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
480
489
  const vfs = new CausalVfs(() => index.invalidate());
481
- const executors = new Map();
490
+ const executors = registry?.executors ?? new Map();
491
+ const definitions = registry?.definitions ?? new Map();
492
+ const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
493
+ let closed = false;
482
494
  const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
483
495
  let callCount = 0;
484
496
  let activeCtx = null;
497
+ let hostSession = null;
498
+ let boundSessionId;
485
499
  let activeSignal = undefined;
486
500
  let trace = [];
487
501
  let callListener = null;
488
502
 
489
- if (pi && isFunction(pi.registerTool)) {
503
+ if (!registry && pi && isFunction(pi.registerTool)) {
490
504
  const original = pi.registerTool.bind(pi);
491
505
  const excluded = new Set(config.excludeTools || []);
492
506
  pi.registerTool = (tool) => {
@@ -498,6 +512,7 @@ export function createHostBridge({ pi, config, getCwd }) {
498
512
  !excluded.has(tool.name)
499
513
  ) {
500
514
  executors.set(tool.name, tool.execute.bind(tool));
515
+ definitions.set(tool.name, tool);
501
516
  }
502
517
  return original(tool);
503
518
  };
@@ -505,10 +520,56 @@ export function createHostBridge({ pi, config, getCwd }) {
505
520
 
506
521
  function bindCallContext(ctx, signal) {
507
522
  activeCtx = ctx || null;
523
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
524
+ boundSessionId = sessionId;
525
+ const registry = pi?.pi?.AgentRegistry?.global?.();
526
+ hostSession = sessionId && registry?.list
527
+ ? registry.list().map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
528
+ : null;
508
529
  activeSignal = signal;
530
+ vfs.signal = signal;
531
+ }
532
+
533
+ function hostTool(name) {
534
+ if (!hostSession) return undefined;
535
+ const metadata = definitions.get(name);
536
+ // Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
537
+ if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
538
+ return hostSession.getToolForEvalBridge?.(name);
539
+ }
540
+
541
+ function isCallable(name) {
542
+ if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
543
+ if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
544
+ // An internal adapter belongs to Supernova, not the host's visible tool list.
545
+ const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
546
+ && (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin");
547
+ if (nativeOwned) return true;
548
+ if (hostSession) {
549
+ if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
550
+ return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
551
+ }
552
+ if (definitions.has(name) && isFunction(pi?.getActiveTools) && !pi.getActiveTools().includes(name)) return false;
553
+ return executors.has(name) || Object.hasOwn(natives, name);
554
+ }
555
+
556
+ function refreshTools() {
557
+ const tools = pi?.getAllTools?.() ?? [];
558
+ for (const tool of tools) {
559
+ if (!isString(tool?.name)) continue;
560
+ definitions.set(tool.name, { ...definitions.get(tool.name), ...tool });
561
+ if (!hostSession && isFunction(tool.execute)) executors.set(tool.name, tool.execute.bind(tool));
562
+ }
563
+ return [...definitions.values()].filter(tool => isCallable(tool.name));
564
+ }
565
+
566
+ function externalNames() {
567
+ return [...definitions.keys()].filter(name => !!hostTool(name) || executors.has(name));
509
568
  }
510
569
 
511
570
  function resetCallBudget() {
571
+ closed = false;
572
+ vfs.closed = false;
512
573
  callCount = 0;
513
574
  trace = [];
514
575
  // Files may change between programs (editor, git); never serve a stale run.
@@ -556,6 +617,7 @@ export function createHostBridge({ pi, config, getCwd }) {
556
617
  }
557
618
 
558
619
  function checkCallBudget(name) {
620
+ if (closed) throw new Error("program is already complete");
559
621
  const maxCalls = config.maxBridgeCalls ?? 256;
560
622
  callCount += 1;
561
623
  if (callCount > maxCalls) {
@@ -598,23 +660,35 @@ export function createHostBridge({ pi, config, getCwd }) {
598
660
 
599
661
  async function invokeRaw(name, args) {
600
662
  checkCallBudget(name);
663
+ const callId = ++sharedRegistry.callSeq;
601
664
  assertCallableTarget(name);
665
+ if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
602
666
 
603
667
  const record = { name, args: args || {}, time: Date.now() };
604
668
  trace.push(record);
605
669
  notifyCall(record);
606
670
 
607
671
  try {
608
- const exec = executors.get(name);
672
+ const delegated = hostTool(name);
673
+ const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
609
674
  if (exec) {
610
675
  const fallbackDiff = await writeFallbackDiff(name, args);
611
- if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
612
- const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
613
- completeRecord(record, res, fallbackDiff);
614
- return res;
676
+ const mutating = isMutatingTool(name, config, args, definitions.get(name));
677
+ if (mutating) await vfs.prepareExternalMutation(name);
678
+ if (activeSignal?.aborted || closed) throw new Error("aborted");
679
+ if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
680
+ try {
681
+ const res = await exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, delegated
682
+ ? { ...activeCtx, settings: hostSession.settings, toolNames: hostSession.getEvalBridgeToolNames(), autoApprove: false }
683
+ : activeCtx);
684
+ completeRecord(record, res, fallbackDiff);
685
+ return res;
686
+ } finally {
687
+ if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); }
688
+ }
615
689
  }
616
690
 
617
- const native = natives[name];
691
+ const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
618
692
  if (native) {
619
693
  const res = await native(args || {}, activeSignal);
620
694
  completeRecord(record, res);
@@ -633,7 +707,7 @@ export function createHostBridge({ pi, config, getCwd }) {
633
707
 
634
708
  function finishRecord(record, res) {
635
709
  record.ms = Date.now() - record.time;
636
- record.ok = res?.isError !== true && res?.details?.ok !== false;
710
+ record.ok = !hostResultFailed(res);
637
711
  const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
638
712
  if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
639
713
  }
@@ -645,14 +719,16 @@ export function createHostBridge({ pi, config, getCwd }) {
645
719
  }
646
720
 
647
721
  async function callMany(calls) {
648
- const list = Array.isArray(calls) ? calls : [];
722
+ if (!Array.isArray(calls)) throw new TypeError("nova.callMany requires an array");
723
+ const list = calls;
724
+ if (list.some(item => !isString(item?.name) || !item.name)) throw new TypeError("nova.callMany entries require a tool name");
649
725
  const thunks = list.map((item) => {
650
726
  const n = item?.name;
651
727
  const a = item?.args;
652
728
  return () => call(n, a);
653
729
  });
654
730
  const names = list.map((item) => item?.name).filter((n) => isString(n));
655
- const wave = await runParallelWave(thunks, { names }, { mode: "auto", config });
731
+ const wave = await runParallelWave(thunks, { names, calls: list, definitions: names.map(name => definitions.get(name)) }, { mode: "auto", config });
656
732
  // Return a results array that also carries .mode/.reason, and is directly
657
733
  // iterable so `for (const r of await nova.callMany([...]))` works.
658
734
  const results = Array.isArray(wave.results) ? wave.results.slice() : [];
@@ -666,7 +742,16 @@ export function createHostBridge({ pi, config, getCwd }) {
666
742
 
667
743
  return {
668
744
  executors,
745
+ definitions,
669
746
  natives,
747
+ refreshTools,
748
+ isCallable,
749
+ externalNames,
750
+ supportsBatchRead: () => !hostTool("read") && !executors.has("read"),
751
+ fork(options) {
752
+ return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork() });
753
+ },
754
+ close() { closed = true; vfs.closed = true; },
670
755
  bindCallContext,
671
756
  resetCallBudget,
672
757
  getTrace,
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { isString, isFunction, isObject } from "./decode.js";
2
+ import { isString, isFunction } from "./decode.js";
3
3
  import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
4
4
  import { loadConfig } from "./config.js";
5
5
  import { createHostBridge } from "./host-bridge.js";
@@ -82,22 +82,22 @@ function errorText(outcome, call) {
82
82
 
83
83
  function successText(outcome, call) {
84
84
  const truncated = outcome.returnTruncated ? " [return truncated]" : "";
85
- const hint = outcome.undefinedReturn ? " (no return statement; add \`return\` to get a value)" : "";
85
+ const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
86
86
  return `ok #${call} ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
87
87
  }
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).
88
+ 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
89
 
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)
90
+ Native commands (async):
91
+ read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
92
+ read("symbol or question") → JSON text with source location and context; no separate search tool needed
93
+ read(path, {about: question}) → relevant file bodies, or source selection inside a directory
94
+ write(path, text) → write a file
95
+ edit(path, oldText, newText) → post-edit lines, checks, and references; no verification read needed
96
+ bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
99
97
 
100
- Already-seen lines collapse to "⋯ N lines same as #12 · path:a–b ⋯"; read(path, a, n) re-shows them.`;
98
+ Source selection reports found, ambiguous, not_found, or incomplete. Only found selects a path. Narrow the directory for uncertain results.
99
+ Optional composition: parallel(thunks), pipeline(items, ...stages), nova.call(name, args), nova.callMany(calls). Use nova.search/describe only for other host tools.
100
+ Already-seen lines collapse to references; read(path, firstLine, lineCount) shows them again. console.log is captured.`;
101
101
 
102
102
  export default function piSupernova(pi) {
103
103
  const config = loadConfig();
@@ -111,50 +111,38 @@ export default function piSupernova(pi) {
111
111
  getCwd: () => cwd,
112
112
  });
113
113
 
114
- function refreshCatalog() {
115
- let tools = [];
116
- try {
117
- if (isFunction(pi.getAllTools)) {
118
- tools = pi.getAllTools() || [];
119
- }
120
- } catch {
121
- tools = [];
122
- }
123
- const discoverable = mergeNativeToolDefinitions(tools, bridge.executors.keys());
114
+ 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
+ });
124
130
  catalog = buildCatalog(discoverable, config.excludeTools || []);
125
131
  return catalog;
126
132
  }
127
133
 
128
- function makeNovaApi() {
134
+ function makeNovaApi(runBridge, runCatalog, cancel) {
129
135
  return {
130
- async search(query, limit) {
131
- const cat = catalog.length ? catalog : refreshCatalog();
132
- const lim = Number.isInteger(limit) ? limit : config.maxSearchResults;
133
- return searchCatalog(cat, query, lim);
134
- },
135
- async describe(name) {
136
- const cat = catalog.length ? catalog : refreshCatalog();
137
- return describeTool(cat, name);
138
- },
139
- async call(name, args) {
140
- return bridge.call(name, args);
141
- },
142
- async callMany(calls) {
143
- return bridge.callMany(calls);
144
- },
145
- speculateBegin() {
146
- bridge.beginSpeculation();
147
- },
148
- async speculateCommit() {
149
- await bridge.commitSpeculation();
150
- },
151
- speculateRollback() {
152
- bridge.rollbackSpeculation();
153
- },
154
- names() {
155
- const cat = catalog.length ? catalog : refreshCatalog();
156
- return [...new Set([...cat.map((t) => t.name), ...bridge.executors.keys(), ...Object.keys(bridge.natives)])];
157
- },
136
+ search: async (query, limit) => searchCatalog(runCatalog, query, Number.isInteger(limit) ? limit : config.maxSearchResults),
137
+ describe: async (name) => describeTool(runCatalog, name),
138
+ call: (name, args) => runBridge.call(name, args),
139
+ callMany: (calls) => runBridge.callMany(calls),
140
+ speculateBegin: () => runBridge.beginSpeculation(),
141
+ speculateCommit: () => runBridge.commitSpeculation(),
142
+ speculateRollback: () => runBridge.rollbackSpeculation(),
143
+ names: () => runCatalog.map(tool => tool.name),
144
+ batchRead: runBridge.supportsBatchRead(),
145
+ cancel,
158
146
  };
159
147
  }
160
148
 
@@ -162,9 +150,9 @@ export default function piSupernova(pi) {
162
150
  name: "supernova",
163
151
  label: "Supernova",
164
152
  description: TOOL_DESCRIPTION,
165
- promptSnippet: "Compose host tools in one JavaScript program",
153
+ promptSnippet: "Use read, write, edit, and bash in one program",
166
154
  promptGuidelines: [
167
- "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.",
155
+ "Use read, write, edit, and bash inside supernova. Start with read(question), or read(directory, {about: question}) for scoped source selection. Read file bodies with read(file, {about: question}). Check source selection status before using its path. Return a compact value.",
168
156
  ],
169
157
  parameters: Type.Object({
170
158
  code: Type.String({ description: "JavaScript program: async body or arrow function." }),
@@ -177,70 +165,62 @@ export default function piSupernova(pi) {
177
165
  renderCall: renderSupernovaCall,
178
166
  renderResult: renderSupernovaResult,
179
167
  async execute(_id, params, signal, onUpdate, ctx) {
180
- if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
168
+ const runCwd = ctx?.cwd || cwd;
181
169
  const runController = new AbortController();
182
170
  const abortRun = () => runController.abort(signal?.reason);
183
171
  if (signal?.aborted) abortRun();
184
172
  else signal?.addEventListener("abort", abortRun, { once: true });
173
+ const runBridge = bridge.fork({ getCwd: () => runCwd });
174
+ runBridge.bindCallContext(ctx, runController.signal);
175
+ runBridge.resetCallBudget();
185
176
 
186
- bridge.bindCallContext(ctx, runController.signal);
187
- bridge.resetCallBudget();
188
- refreshCatalog();
189
- bridge.beginSpeculation();
190
177
  const call = ++programSeq;
191
- bridge.ledger.beginProgram(call);
178
+ runBridge.ledger.beginProgram(call);
192
179
  const emitProgress = progressEmitter(onUpdate);
193
- bridge.setCallListener((_record, allTrace) => emitProgress(allTrace));
180
+ runBridge.setCallListener((_record, trace) => emitProgress(trace));
194
181
  emitProgress([]);
195
-
182
+ const started = performance.now();
196
183
  let outcome;
197
184
  try {
198
- outcome = await runProgram(params, runController.signal, abortRun);
185
+ const runCatalog = refreshCatalog(runBridge);
186
+ runBridge.beginSpeculation();
187
+ outcome = await runGuestProgram({
188
+ code: params?.code,
189
+ nova: makeNovaApi(runBridge, runCatalog, abortRun),
190
+ config: { ...config, timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs },
191
+ signal: runController.signal,
192
+ onTimeout: abortRun,
193
+ });
194
+ runBridge.close();
195
+ if (outcome.ok) {
196
+ if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished nova.speculate branch; await it before returning");
197
+ await runBridge.commitSpeculation();
198
+ }
199
+ else runBridge.rollbackSpeculation();
200
+ } catch (error) {
201
+ abortRun();
202
+ runBridge.close();
203
+ runBridge.rollbackSpeculation();
204
+ outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
199
205
  } finally {
200
- bridge.setCallListener(null);
206
+ runBridge.setCallListener(null);
201
207
  emitProgress.flush();
202
208
  signal?.removeEventListener("abort", abortRun);
203
209
  }
204
-
205
- const trace = bridge.getTrace();
206
- if (!outcome.ok) {
207
- bridge.rollbackSpeculation();
208
- return result(bridge.ledger.dedupe(errorText(outcome, call), call), { ok: false, error: outcome.error, wallMs: outcome.wallMs, logs: outcome.logs, trace });
209
- }
210
- await bridge.commitSpeculation();
211
- return result(bridge.ledger.dedupe(successText(outcome, call), call), {
212
- ok: true,
213
- wallMs: outcome.wallMs,
214
- returnTruncated: outcome.returnTruncated,
215
- logTruncated: outcome.logTruncated,
216
- logs: outcome.logs,
217
- result: outcome.result,
218
- trace,
210
+ const trace = runBridge.getTrace();
211
+ const text = outcome.ok ? successText(outcome, call) : errorText(outcome, call);
212
+ return result(runBridge.ledger.dedupe(text, call), {
213
+ ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
214
+ returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
215
+ logs: outcome.logs, result: outcome.result, trace,
219
216
  });
220
217
  },
221
218
  });
222
219
 
223
- async function runProgram(params, signal, onTimeout) {
224
- const runStartedAt = performance.now();
225
- const runConfig = {
226
- ...config,
227
- timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
228
- };
229
- try {
230
- return await runGuestProgram({ code: String(params?.code || ""), nova: makeNovaApi(), config: runConfig, signal, onTimeout });
231
- } catch (error) {
232
- return {
233
- ok: false,
234
- error: error instanceof Error ? error.message : String(error),
235
- logs: [],
236
- wallMs: Math.round(performance.now() - runStartedAt),
237
- };
238
- }
239
- }
240
-
241
220
  pi.on("session_start", (_event, ctx) => {
242
221
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
243
222
  // A new session is a new model context: nothing has been seen yet.
223
+ bridge.bindCallContext(ctx);
244
224
  bridge.ledger.reset();
245
225
  programSeq = 0;
246
226
  refreshCatalog();
@@ -248,14 +228,15 @@ export default function piSupernova(pi) {
248
228
  });
249
229
 
250
230
  pi.registerCommand("supernova", {
251
- description: "Show pi-supernova status (catalog size, captured executors)",
231
+ description: "Show pi-supernova status (callable tools and session statistics)",
252
232
  handler: async (_args, ctx) => {
233
+ bridge.bindCallContext(ctx);
253
234
  refreshCatalog();
254
- const captured = [...bridge.executors.keys()].sort();
255
- const natives = Object.keys(bridge.natives).sort();
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();
256
237
  const lines = [
257
238
  `pi-supernova catalog: ${catalog.length} tools`,
258
- `captured executors: ${captured.length ? captured.join(", ") : "(none yet; load this package early)"}`,
239
+ `external tools: ${external.length ? external.join(", ") : "(none)"}`,
259
240
  `native adapters: ${natives.join(", ")}`,
260
241
  `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
261
242
  sessionStats(bridge.ledger.stats),