pi-supernova 0.0.8 → 0.0.15

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
@@ -8,9 +8,16 @@ import { unknownToolMessage } from "./catalog.js";
8
8
  import { extractStructuralSurface } from "./surface.js";
9
9
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
10
  import { executeSnap } from "./snap.js";
11
+ import { selectEvidence } from "./evidence.js";
12
+ import { WorkspaceIndex } from "./repo-index.js";
13
+ import { outlineFile } from "./outline.js";
14
+ import { SeenLedger } from "./ledger.js";
15
+ import { quickCheck } from "./check.js";
16
+ import { declaredName } from "./repo-index.js";
11
17
  import { CausalVfs } from "./vfs.js";
12
18
  import { applyPatchToText } from "./patch.js";
13
- import { resolveWorkspacePath, runCommand } from "./workspace.js";
19
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "./workspace.js";
20
+ import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs } from "./search.js";
14
21
 
15
22
  function textResult(text, details) {
16
23
  return {
@@ -61,15 +68,6 @@ async function probeExistingFile(cwd, targetParam, vfs) {
61
68
  }
62
69
  }
63
70
 
64
- function patchModeOf(params) {
65
- return (
66
- isString(params?.patch) ||
67
- (params?.newText === undefined &&
68
- isString(params?.oldText) &&
69
- (params.oldText.includes("@@ -") || params.oldText.startsWith("---")))
70
- );
71
- }
72
-
73
71
  function applyReplacements(target, content, requestedEdits) {
74
72
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
75
73
  const matches = requestedEdits.map((replacement) => {
@@ -113,7 +111,7 @@ async function formatLsEntry(dirPath, entry) {
113
111
  return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
114
112
  }
115
113
 
116
- function createNativeAdapters(getCwd, vfs, config) {
114
+ function createNativeAdapters(getCwd, vfs, config, index, ledger) {
117
115
  async function readAdapter(params, signal) {
118
116
  const cwd = getCwd();
119
117
  const targetParam = params?.path ?? params?.target;
@@ -128,31 +126,153 @@ function createNativeAdapters(getCwd, vfs, config) {
128
126
 
129
127
  if (looksLikePath(targetParam)) {
130
128
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
131
- const text = await vfs.read(targetPath);
132
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
129
+ return readFile(targetPath, params);
133
130
  }
134
131
 
135
132
  const existing = await probeExistingFile(cwd, targetParam, vfs);
136
- if (existing) {
137
- const text = await vfs.read(existing);
138
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: existing });
139
- }
133
+ if (existing) return readFile(existing, params);
140
134
 
141
135
  if (isString(targetParam) && targetParam.trim()) {
142
136
  try {
143
137
  const snapRes = await executeSnap({
144
138
  query: targetParam,
145
139
  searchDir: cwd,
146
- vfs,
147
- runCommand: (argv, opts) => runCommand(argv, { cwd, signal, ...opts }),
140
+ index,
141
+ overlayText: (p) => vfs.getOverlay(p),
142
+ pendingPaths: vfs.getOverlayPaths(),
148
143
  });
149
144
  return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
150
145
  } catch {}
151
146
  }
152
147
 
153
148
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
154
- const text = await vfs.read(targetPath);
155
- return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
149
+ return readFile(targetPath, params);
150
+ }
151
+
152
+ /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
153
+ async function readFile(targetPath, params) {
154
+ const cwd = getCwd();
155
+ const rel = relativeSlash(cwd, targetPath);
156
+ const text = await vfs.read(targetPath);
157
+ index.touch(rel);
158
+ if (isString(params?.about)) {
159
+ const pending = vfs.getOverlay(targetPath);
160
+ const entry = pending === undefined ? index.entry(targetPath) : WorkspaceIndex.fromText(targetPath, pending);
161
+ const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
162
+ if (outline) {
163
+ recordOutlineOrigins(rel, outline.text);
164
+ return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
165
+ }
166
+ }
167
+ const explicit = isNumber(params?.offset) || isNumber(params?.limit);
168
+ const firstLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
169
+ const sliced = sliceLines(text, params?.offset, params?.limit);
170
+ ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
171
+ return textResult(sliced, { path: targetPath });
172
+ }
173
+
174
+ /**
175
+ * The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
176
+ * lines with numbers (so no verification re-read), a quick structural check, and every other
177
+ * place a changed declaration is referenced (so callers are not forgotten).
178
+ */
179
+ async function editSummary(cwd, target, original, updated, diff) {
180
+ const rel = relativeSlash(cwd, target);
181
+ const newLines = updated.split("\n");
182
+ const added = diff.lines.filter((l) => l.type === "add");
183
+ const first = added.length ? Math.max(1, added[0].lineNum - 2) : 1;
184
+ const last = added.length ? Math.min(newLines.length, added[added.length - 1].lineNum + 2) : Math.min(newLines.length, first + 6);
185
+ const window = [];
186
+ for (let l = first; l <= last && window.length < 40; l++) window.push(String(l).padStart(5) + " " + newLines[l - 1]);
187
+ ledger.recordOrigin(rel, first, newLines.slice(first - 1, first - 1 + window.length));
188
+ let out = `edited ${rel}:${first}–${first + window.length - 1}\n${window.join("\n")}`;
189
+ const check = quickCheck(updated, path.extname(target));
190
+ if (check && !check.ok) out += `\ncheck: ${check.message}`;
191
+ const refs = await changedDeclarationRefs(cwd, target, original, updated, diff);
192
+ if (refs) out += `\n${refs}`;
193
+ return out;
194
+ }
195
+
196
+ async function changedDeclarationRefs(cwd, target, original, updated, diff) {
197
+ // Diff rows carry the replaced fragments; declarations live on whole file lines.
198
+ const oldLines = original.split("\n");
199
+ const newLines = updated.split("\n");
200
+ const names = new Set();
201
+ for (const l of diff.lines) {
202
+ if (l.type === "context") continue;
203
+ const name = declaredName((l.type === "remove" ? oldLines : newLines)[l.lineNum - 1] ?? "");
204
+ if (name) names.add(name);
205
+ }
206
+ if (names.size === 0) return "";
207
+ const find = await referenceFinder(cwd, target);
208
+ const parts = [];
209
+ for (const name of [...names].slice(0, 3)) {
210
+ const refs = find(name).filter((r) => !r.startsWith(relativeSlash(cwd, target) + ":"));
211
+ if (refs.length) parts.push(`${name} also referenced in ${refs.slice(0, 6).join(", ")}${refs.length > 6 ? " (+" + (refs.length - 6) + ")" : ""}`);
212
+ }
213
+ return parts.join("\n");
214
+ }
215
+
216
+ const SOURCE_REF = /((?:[\w.@-]+\/)*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
217
+
218
+ /** Source window (±2 lines, ► on the cited line) for one path:line, or null when it is outside the workspace/index. */
219
+ function sourceWindow(cwd, file, lineNo) {
220
+ const candidate = path.resolve(cwd, file);
221
+ if (!candidate.startsWith(path.resolve(cwd) + path.sep)) return null;
222
+ const entry = index.entry(candidate);
223
+ if (!entry) return null;
224
+ const { raw } = WorkspaceIndex.linesOf(entry);
225
+ if (lineNo < 1 || lineNo > raw.length) return null;
226
+ const rel = relativeSlash(cwd, candidate);
227
+ const start = Math.max(1, lineNo - 2);
228
+ const rows = [];
229
+ for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
230
+ ledger.recordOrigin(rel, start, rows);
231
+ return rel + ":" + lineNo + "\n" + rows.join("\n");
232
+ }
233
+
234
+ /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
235
+ function sourceForReferences(cwd, output) {
236
+ const seen = new Set();
237
+ const blocks = [];
238
+ for (const m of output.matchAll(SOURCE_REF)) {
239
+ const key = m[1] + ":" + m[2];
240
+ if (seen.has(key)) continue;
241
+ seen.add(key);
242
+ const block = sourceWindow(cwd, m[1], Number(m[2]));
243
+ if (block) blocks.push(block);
244
+ if (blocks.length >= 4) break;
245
+ }
246
+ return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
247
+ }
248
+
249
+ function outlineOptions(params, references) {
250
+ const options = { references };
251
+ if (params?.maxChars) options.maxChars = params.maxChars;
252
+ return options;
253
+ }
254
+
255
+ /** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
256
+ function recordOutlineOrigins(rel, outlineText) {
257
+ for (const line of outlineText.split("\n")) {
258
+ const m = /^\s*(\d+) (.*)$/.exec(line);
259
+ if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
260
+ }
261
+ }
262
+
263
+ /** Where else a name appears (declaration line excluded), for outlines and edit results. */
264
+ async function referenceFinder(cwd, targetPath) {
265
+ const files = await index.files(cwd);
266
+ if (!index.canScan(files)) return () => [];
267
+ return (name, excludeLine) => {
268
+ if (!name || name.length < 3) return [];
269
+ const escaped = name.replace(/[$]/g, (c) => "\\" + c);
270
+ const regex = new RegExp("\\b" + escaped + "\\b");
271
+ return index
272
+ .grepRows(files, regex, cwd)
273
+ .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
274
+ .map((r) => r.rel + ":" + r.line);
275
+ };
156
276
  }
157
277
 
158
278
  return {
@@ -167,6 +287,7 @@ function createNativeAdapters(getCwd, vfs, config) {
167
287
  prevText = await vfs.read(target);
168
288
  } catch {}
169
289
  const { speculative } = await vfs.write(target, content);
290
+ index.touch(relativeSlash(cwd, target));
170
291
  const diff = buildWriteDiff(target, prevText, content);
171
292
  const tag = speculative ? " (speculative)" : "";
172
293
  return textResult(`wrote ${target}${tag}`, { path: target, speculative, diff });
@@ -176,33 +297,20 @@ function createNativeAdapters(getCwd, vfs, config) {
176
297
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
177
298
  if (signal?.aborted) throw new Error("aborted");
178
299
 
179
- if (patchModeOf(params)) {
180
- const patchContent = params.patch || params.oldText;
181
- const original = await vfs.read(target);
182
- const { resultText, hunkCount } = applyPatchToText(original, patchContent);
183
- const { speculative } = await vfs.write(target, resultText);
184
- const diff = buildPatchDiff(target, patchContent);
185
- const tag = speculative ? " (speculative)" : "";
186
- return textResult(`applied ${hunkCount} hunk(s) to ${target}${tag}`, {
187
- path: target,
188
- hunks: hunkCount,
189
- speculative,
190
- diff,
191
- });
192
- }
193
-
194
300
  const requestedEdits = Array.isArray(params?.edits)
195
301
  ? params.edits
196
302
  : [{ oldText: params?.oldText, newText: params?.newText }];
197
303
  const content = await vfs.read(target);
198
304
  const { updated, matches } = applyReplacements(target, content, requestedEdits);
199
305
  const { speculative } = await vfs.write(target, updated);
306
+ index.touch(relativeSlash(cwd, target));
200
307
  const diff =
201
308
  matches.length === 1
202
309
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
203
310
  : buildMultiEditDiff(target, content, matches);
204
311
  const tag = speculative ? " (speculative)" : "";
205
- return textResult(`edited ${target}${tag}`, { path: target, speculative, diff });
312
+ const summary = await editSummary(cwd, target, content, updated, diff);
313
+ return textResult(summary, { path: target, speculative, diff });
206
314
  },
207
315
  async apply_patch(params, signal) {
208
316
  const cwd = getCwd();
@@ -243,19 +351,26 @@ function createNativeAdapters(getCwd, vfs, config) {
243
351
  const res = await executeSnap({
244
352
  query: params.query,
245
353
  searchDir: snapTarget,
354
+ root: cwd,
246
355
  includeHidden,
247
- vfs: {
248
- read: async (candidate) => {
249
- // Jail each snap candidate (symlink files must not escape the workspace).
250
- const jailed = await resolveWorkspacePath(cwd, candidate, "snap", false);
251
- return vfs.read(jailed);
252
- },
253
- },
254
- runCommand: (argv, opts) => runCommand(argv, { cwd: snapTarget, signal, ...opts }),
356
+ index,
357
+ overlayText: (p) => vfs.getOverlay(p),
255
358
  pendingPaths: vfs.getOverlayPaths(),
256
359
  });
257
360
  return textResult(JSON.stringify(res, null, 2), res);
258
361
  },
362
+ async evidence(params, signal) {
363
+ const cwd = getCwd();
364
+ if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
365
+ if (signal?.aborted) throw new Error("aborted");
366
+ const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
367
+ const options = {};
368
+ if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
369
+ 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 });
371
+ for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
372
+ return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
373
+ },
259
374
  async surface(params, signal) {
260
375
  const cwd = getCwd();
261
376
  const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
@@ -286,9 +401,11 @@ function createNativeAdapters(getCwd, vfs, config) {
286
401
  });
287
402
  } finally {
288
403
  vfs.invalidateCache();
404
+ index.invalidate();
289
405
  }
290
406
  const { stdout, stderr } = res;
291
- const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
407
+ let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
408
+ if (res.exitCode !== 0) text += sourceForReferences(cwd, text);
292
409
  return {
293
410
  content: [{ type: "text", text }],
294
411
  details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -300,11 +417,10 @@ function createNativeAdapters(getCwd, vfs, config) {
300
417
  const pattern = String(params?.pattern || "");
301
418
  if (!pattern) throw new Error("grep requires pattern");
302
419
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
303
- const args = ["--line-number", "--no-heading", "--color", "never"];
304
- if (params?.caseSensitive !== true) args.push("--ignore-case");
305
- if (params?.glob) args.push("--glob", String(params.glob));
306
- args.push("--", pattern, searchPath);
307
- const res = await runCommand(["rg", ...args], { cwd, timeoutMs: 30_000, signal });
420
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
421
+ if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
422
+ // Large tree: real rg keeps its own output format.
423
+ const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
308
424
  if (res.exitCode !== 0 && res.exitCode !== 1) {
309
425
  throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
310
426
  }
@@ -314,6 +430,10 @@ function createNativeAdapters(getCwd, vfs, config) {
314
430
  const cwd = getCwd();
315
431
  const pattern = String(params?.pattern || "");
316
432
  if (!pattern) throw new Error("glob requires pattern");
433
+ const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
434
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
435
+ const indexed = await listIndexed(index, cwd, cwd, pattern);
436
+ if (indexed !== null) return textResult(indexed, { via: "index" });
317
437
  const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
318
438
  () => null,
319
439
  );
@@ -333,16 +453,12 @@ function createNativeAdapters(getCwd, vfs, config) {
333
453
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
334
454
  const pattern = params?.pattern || params?.glob;
335
455
  if (signal?.aborted) throw new Error("aborted");
336
- const args = ["--files"];
337
- if (pattern) args.push("-g", String(pattern));
338
- const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
339
- if (res && (res.exitCode === 0 || res.exitCode === 1)) {
340
- return textResult(res.stdout, { via: "rg" });
341
- }
342
- const findArgs = [searchDir];
343
- if (pattern) findArgs.push("-name", String(pattern));
344
- const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
345
- return textResult(findRes.stdout, { via: "find" });
456
+ const globPattern = pattern ? String(pattern) : null;
457
+ const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
458
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
459
+ const indexed = await listIndexed(index, searchDir, cwd, globPattern);
460
+ if (indexed !== null) return textResult(indexed, { via: "index" });
461
+ return listWithTools(searchDir, globPattern, cwd, signal);
346
462
  },
347
463
  async ls(params, signal) {
348
464
  const cwd = getCwd();
@@ -359,9 +475,11 @@ function createNativeAdapters(getCwd, vfs, config) {
359
475
  }
360
476
 
361
477
  export function createHostBridge({ pi, config, getCwd }) {
362
- const vfs = new CausalVfs();
478
+ const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
479
+ const ledger = new SeenLedger({ window: config.seenWindow ?? 40 });
480
+ const vfs = new CausalVfs(() => index.invalidate());
363
481
  const executors = new Map();
364
- const natives = createNativeAdapters(getCwd, vfs, config);
482
+ const natives = createNativeAdapters(getCwd, vfs, config, index, ledger);
365
483
  let callCount = 0;
366
484
  let activeCtx = null;
367
485
  let activeSignal = undefined;
@@ -395,6 +513,7 @@ export function createHostBridge({ pi, config, getCwd }) {
395
513
  trace = [];
396
514
  // Files may change between programs (editor, git); never serve a stale run.
397
515
  vfs.invalidateCache();
516
+ clearPathCache();
398
517
  }
399
518
 
400
519
  function getTrace() {
@@ -405,10 +524,6 @@ export function createHostBridge({ pi, config, getCwd }) {
405
524
  callListener = isFunction(fn) ? fn : null;
406
525
  }
407
526
 
408
- function hasExecutor(name) {
409
- return executors.has(name) || Object.hasOwn(natives, name);
410
- }
411
-
412
527
  function beginSpeculation() {
413
528
  return vfs.begin();
414
529
  }
@@ -421,10 +536,6 @@ export function createHostBridge({ pi, config, getCwd }) {
421
536
  return vfs.rollback();
422
537
  }
423
538
 
424
- function clearVfsCache() {
425
- vfs.clear();
426
- }
427
-
428
539
  function resultDiff(response) {
429
540
  let details = response?.details;
430
541
  if (isString(details)) {
@@ -514,6 +625,7 @@ export function createHostBridge({ pi, config, getCwd }) {
514
625
  } catch (error) {
515
626
  record.ok = false;
516
627
  record.ms = Date.now() - record.time;
628
+ record.error = error instanceof Error ? error.message : String(error);
517
629
  notifyCall(record);
518
630
  throw error;
519
631
  }
@@ -559,15 +671,13 @@ export function createHostBridge({ pi, config, getCwd }) {
559
671
  resetCallBudget,
560
672
  getTrace,
561
673
  setCallListener,
562
- hasExecutor,
563
674
  beginSpeculation,
564
675
  commitSpeculation,
565
676
  rollbackSpeculation,
566
- clearVfsCache,
567
677
  getVfsCacheSize: () => vfs.getCacheSize(),
568
678
  getOverlayDepth: () => vfs.getOverlayDepth(),
569
679
  call,
570
680
  callMany,
571
- isMutating: (name) => isMutatingTool(name, config),
681
+ ledger,
572
682
  };
573
683
  }
package/index.js CHANGED
@@ -4,16 +4,11 @@ import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions }
4
4
  import { loadConfig } from "./config.js";
5
5
  import { createHostBridge } from "./host-bridge.js";
6
6
  import { runGuestProgram, warmGuestWorker } from "./runtime.js";
7
- import {
8
- extractOperationsFromCode,
9
- renderSupernovaCall,
10
- renderSupernovaResult,
11
- SafeText,
12
- } from "./render.js";
7
+ import { renderSupernovaCall, renderSupernovaResult } from "./render.js";
13
8
 
14
- export { extractOperationsFromCode, renderSupernovaCall, renderSupernovaResult, SafeText };
9
+ export { renderSupernovaCall, renderSupernovaResult };
15
10
 
16
- // Sync only — never top-level await. Dynamic import of host/deps hung OMP plugin load.
11
+ // Sync only, never top-level await. Dynamic import of host/deps hung OMP plugin load.
17
12
  const require = createRequire(import.meta.url);
18
13
  let Type;
19
14
  try {
@@ -31,57 +26,84 @@ function result(text, details) {
31
26
  return { content: [{ type: "text", text }], details };
32
27
  }
33
28
 
34
- /** Live trace updates for the card; a throwing host callback must never break the run. */
29
+ const PROGRESS_FRAME_MS = 40;
30
+
31
+ /**
32
+ * Live trace updates for the card. The first update is immediate (seeds the result slot);
33
+ * later ones are coalesced to one host re-render per frame so a tight loop of nova.calls
34
+ * is not throttled by the TUI. A throwing host callback must never break the run.
35
+ */
35
36
  function progressEmitter(onUpdate) {
36
- if (!isFunction(onUpdate)) return () => {};
37
- return (trace) => {
37
+ if (!isFunction(onUpdate)) return Object.assign(() => {}, { flush() {} });
38
+ let pending = null;
39
+ let timer = null;
40
+ const send = (trace) => {
38
41
  try {
39
42
  onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
40
43
  } catch {}
41
44
  };
45
+ const flush = () => {
46
+ timer = null;
47
+ if (pending === null) return;
48
+ const trace = pending;
49
+ pending = null;
50
+ send(trace);
51
+ };
52
+ const emit = (trace) => {
53
+ if (timer === null && pending === null) {
54
+ send(trace);
55
+ timer = setTimeout(flush, PROGRESS_FRAME_MS);
56
+ return;
57
+ }
58
+ pending = trace;
59
+ if (timer === null) timer = setTimeout(flush, PROGRESS_FRAME_MS);
60
+ };
61
+ emit.flush = () => {
62
+ if (timer !== null) clearTimeout(timer);
63
+ pending = null;
64
+ timer = null;
65
+ };
66
+ return emit;
67
+ }
68
+
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)`;
42
73
  }
43
74
 
44
75
  function logsBlock(outcome, tail = "") {
45
76
  return outcome.logs?.length ? `\n--- logs\n${outcome.logs.join("\n")}${tail}` : "";
46
77
  }
47
78
 
48
- function errorText(outcome) {
49
- return `error ${outcome.wallMs}ms: ${outcome.error}${logsBlock(outcome)}`;
79
+ function errorText(outcome, call) {
80
+ return `error #${call} ${outcome.wallMs}ms: ${outcome.error}${logsBlock(outcome)}`;
50
81
  }
51
82
 
52
- function successText(outcome) {
83
+ function successText(outcome, call) {
53
84
  const truncated = outcome.returnTruncated ? " [return truncated]" : "";
54
- const hint = outcome.undefinedReturn ? " (no return statement — add \`return\` to get a value)" : "";
55
- return `ok ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
85
+ const hint = outcome.undefinedReturn ? " (no return statement; add \`return\` to get a value)" : "";
86
+ return `ok #${call} ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
56
87
  }
57
- function unwrapStructuredResult(response, operation) {
58
- if (response?.ok === false) {
59
- throw new Error(response.value || response.error || `${operation} failed`);
60
- }
61
- const value = isObject(response) && "value" in response ? response.value : response;
62
- if (!isString(value)) return value;
63
- try {
64
- return JSON.parse(value);
65
- } catch {
66
- return value;
67
- }
68
- }
69
-
70
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).
71
89
 
72
90
  Globals (async):
73
- read(path|paths, offset?, limit?) → text | text[]
74
- write(path, text) · edit(path, oldText, newText) · patch(path, unifiedDiff)
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)
75
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
76
95
  snap(query, root?) → {path, line, signature, context} · surface(path) → {items: [{name, kind, line}]}
77
96
  nova.call(name, args) → {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
78
97
  nova.search(query) → [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
79
- parallel(thunks) · pipeline(items, ...stages)`;
98
+ parallel(thunks) · pipeline(items, ...stages)
99
+
100
+ Already-seen lines collapse to "⋯ N lines same as #12 · path:a–b ⋯"; read(path, a, n) re-shows them.`;
80
101
 
81
102
  export default function piSupernova(pi) {
82
103
  const config = loadConfig();
83
104
  let cwd = process.cwd();
84
105
  let catalog = [];
106
+ let programSeq = 0;
85
107
 
86
108
  const bridge = createHostBridge({
87
109
  pi,
@@ -133,12 +155,6 @@ export default function piSupernova(pi) {
133
155
  const cat = catalog.length ? catalog : refreshCatalog();
134
156
  return [...new Set([...cat.map((t) => t.name), ...bridge.executors.keys(), ...Object.keys(bridge.natives)])];
135
157
  },
136
- async surface(filePath) {
137
- return unwrapStructuredResult(await bridge.call("surface", { path: filePath }), "surface");
138
- },
139
- async snap(query, targetPath) {
140
- return unwrapStructuredResult(await bridge.call("snap", { query, path: targetPath }), "snap");
141
- },
142
158
  };
143
159
  }
144
160
 
@@ -148,7 +164,7 @@ export default function piSupernova(pi) {
148
164
  description: TOOL_DESCRIPTION,
149
165
  promptSnippet: "Compose host tools in one JavaScript program",
150
166
  promptGuidelines: [
151
- "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. Return a compact shaped value; keep raw tool output inside the program.",
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.",
152
168
  ],
153
169
  parameters: Type.Object({
154
170
  code: Type.String({ description: "JavaScript program: async body or arrow function." }),
@@ -171,6 +187,8 @@ export default function piSupernova(pi) {
171
187
  bridge.resetCallBudget();
172
188
  refreshCatalog();
173
189
  bridge.beginSpeculation();
190
+ const call = ++programSeq;
191
+ bridge.ledger.beginProgram(call);
174
192
  const emitProgress = progressEmitter(onUpdate);
175
193
  bridge.setCallListener((_record, allTrace) => emitProgress(allTrace));
176
194
  emitProgress([]);
@@ -180,16 +198,17 @@ export default function piSupernova(pi) {
180
198
  outcome = await runProgram(params, runController.signal, abortRun);
181
199
  } finally {
182
200
  bridge.setCallListener(null);
201
+ emitProgress.flush();
183
202
  signal?.removeEventListener("abort", abortRun);
184
203
  }
185
204
 
186
205
  const trace = bridge.getTrace();
187
206
  if (!outcome.ok) {
188
207
  bridge.rollbackSpeculation();
189
- return result(errorText(outcome), { ok: false, error: outcome.error, wallMs: outcome.wallMs, logs: outcome.logs, trace });
208
+ return result(bridge.ledger.dedupe(errorText(outcome, call), call), { ok: false, error: outcome.error, wallMs: outcome.wallMs, logs: outcome.logs, trace });
190
209
  }
191
210
  await bridge.commitSpeculation();
192
- return result(successText(outcome), {
211
+ return result(bridge.ledger.dedupe(successText(outcome, call), call), {
193
212
  ok: true,
194
213
  wallMs: outcome.wallMs,
195
214
  returnTruncated: outcome.returnTruncated,
@@ -221,6 +240,9 @@ export default function piSupernova(pi) {
221
240
 
222
241
  pi.on("session_start", (_event, ctx) => {
223
242
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
243
+ // A new session is a new model context: nothing has been seen yet.
244
+ bridge.ledger.reset();
245
+ programSeq = 0;
224
246
  refreshCatalog();
225
247
  warmGuestWorker(config).catch(() => {});
226
248
  });
@@ -233,9 +255,10 @@ export default function piSupernova(pi) {
233
255
  const natives = Object.keys(bridge.natives).sort();
234
256
  const lines = [
235
257
  `pi-supernova catalog: ${catalog.length} tools`,
236
- `captured executors: ${captured.length ? captured.join(", ") : "(none yet — load this package early)"}`,
258
+ `captured executors: ${captured.length ? captured.join(", ") : "(none yet; load this package early)"}`,
237
259
  `native adapters: ${natives.join(", ")}`,
238
260
  `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
261
+ sessionStats(bridge.ledger.stats),
239
262
  ];
240
263
  ctx.ui.notify(lines.join("\n"), "info");
241
264
  },