pi-supernova 0.4.0 → 0.5.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/README.md CHANGED
@@ -50,12 +50,13 @@ settings. The runtime does not silently rewrite your tool policy.
50
50
  | `read` | `read(path, offset?, limit?)`, `read({path,offset,limit})`; one-based line windows |
51
51
  | `read` | `return await read("plot.png")`; displays images directly, without a browser |
52
52
  | `read` | `read({path,json:".verdict"})`; parse full JSON before bounded field selection |
53
- | `read` | `read(directory)`, `read("symbol or question")`, `read(path,{about:question})`; questions locate and open source directly |
54
- | `read` | `read({query,resolve:true})`; structured source and status for a resolve-to-edit handoff |
53
+ | `read` | `read(directory)`, `read("symbol or question")`, `read(path,{about:question})`; a path is raw text, a symbol is a view |
54
+ | `read` | `read({query,resolve:true})`; same view as `read("symbol")`: status, path, line, lines, text, complete |
55
55
  | `read` | `read({query,evidence:true})`; ranked evidence with provenance; optional `path` scopes discovery |
56
56
  | `read` | `read({path,outline:true})`; structural declarations |
57
57
  | `read` | `read([path1,path2])`; up to 64 paths, ordered values with labelled individual failures |
58
- | `edit` | `edit(path,oldText,newText)`, `edit({path,edits:[{oldText,newText}]})`; related edits validated against one original file |
58
+ | `edit` | `edit(path,oldText,newText)`, `edit({path,edits:[{oldText,newText}]})`; unique in the file |
59
+ | `edit` | `edit(view,text)` CAS-replaces that span; `edit(view,old,new)` is unique inside it |
59
60
  | `edit` | `edit({path,patch})`; unified patch application |
60
61
  | `edit` | `edit(async () => {...})`; filesystem-only checkpoint, described below |
61
62
  | `write` | `write(path,text)`, `write({path,content})`; atomic replacement |
@@ -70,27 +71,26 @@ payloads are not repeated in owned direct-execution errors;
70
71
  stdout/stderr, exit status and source context remain. Session environment variables are taken
71
72
  from the current execution context, not inherited from a different parent session.
72
73
 
73
- Source questions resolve and open the selected file in one command. An exact
74
+ Source questions locate a declaration in one command. An exact
74
75
  declaration match uses one bounded direct ripgrep search, without a prerequisite
75
76
  file listing, persistent index, embeddings or summarization. A transient filename
76
77
  listing is a fallback for unmatched content or unresolved bare filenames. Natural-language
77
78
  questions reuse lexical stemming. Ripgrep must be available on PATH.
78
79
 
79
- Successful question reads return raw source with a path/range header, not the old
80
- JSON location preview. Use the structured form when code needs the path:
80
+ `read(path)` stays raw text. `read("symbol")` is the same view as
81
+ `read({query, resolve:true})` — not the file, not a path/range header:
81
82
 
82
83
  ```javascript
83
- const source = await read({query: "validateRefreshToken", resolve: true});
84
- if (source.status !== "found") return source;
85
- return await edit(source.path, "token.length > 3", "token.length > 5");
84
+ const v = await read("validateRefreshToken");
85
+ if (v.status !== "found") return v;
86
+ await edit(v, v.text.replace("token.length > 3", "token.length > 5"));
86
87
  ```
87
88
 
88
- The structured result contains `status`, `path`, the matching `line`, delivered
89
- `lines`, unchanged `text`, `complete`, and `nextOffset` when more source follows.
90
- Files that fit the output budget are returned in full. Oversized files open near
91
- the matching line and give a continuation; they are not summarized. Uncertain
89
+ The view contains `status`, `path`, the matching `line`, span `lines`, unchanged
90
+ `text`, `complete`, and `nextOffset` when a budget clip continues. A declaration
91
+ snap is that span (`complete` is false unless the span is the whole file). Uncertain
92
92
  results report `ambiguous`, `not_found` or `incomplete` with no selected path.
93
- Use `{path: directory, about: question, resolve: true}` to narrow the scope.
93
+ Use `{path: directory, about: question}` to narrow the scope.
94
94
 
95
95
  Ordinary reads stay self-contained. Outlines and graph evidence remain explicit
96
96
  options, not mandatory stages of source resolution. Ordinary calls also get:
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-09-12
4
+
5
+ - Report overlapping `supernova` calls from every participant in the wave, including the first-started call that finishes last. Start-order or finish-order counters missed that side; a peak concurrent count resets when the wave drains. Sequential calls, `programs` batches, and failed programs still do not leak a split hint. The regression forces the slower first program so the last-finisher case cannot flake under load.
6
+
7
+ - Shrink the standing tool definition without compressing source or results. Duplicate object-form restatements, schema prose already covered by the command list, and discoverable operational asides are gone; signatures and safety rules stay in the always-sent reference. Frozen six-call traffic is 14,970 / 14,787 tokens (o200k / cl100k): 19.23% / 19.24% below d444eb7. The current-pass gate now requires 19% vs d444eb7.
8
+
9
+ - An unmatched or non-unique edit keeps the file and returns a numbered window of the actual source (16-line cap, same coordinates as a successful edit) so the next program can copy oldText without a blind re-read. Successful read/write/edit results are unchanged.
10
+
11
+ - A program that writes or edits and returns nothing still delivers those mutation receipts (numbered post-edit lines, `wrote` paths). Reads without a return stay a no-return hint and do not dump file contents. Explicit `return await edit(...)` is not duplicated.
12
+
13
+ - `edit(view, text)` replaces a `resolve:true` window by line span with CAS against the viewed bytes. Duplicate substrings no longer block a ranged edit; a stale view fails and rolls back. The silent receipt names that span (`edited path:2-2`), not a ±2 context window.
14
+
15
+ - A `resolve:true` snap of a declaration is that declaration's span, not the whole file just because the file fits the read budget. `edit(view, text)` then replaces the function, not the file. `edit(view, old, new)` is unique inside that span. A budget-clipped view (`nextOffset`) is not editable.
16
+
17
+ - Gravity: `read("symbol")` is the same view as `read({query, resolve:true})`. `read(path)` stays raw text. `looksLikePath` lives in `shared/decode.js` so guest and host share the identifier-vs-path rule.
18
+
19
+ - Nested declaration spans: a parent still includes its body (brace-matched from the opening line), one-line class methods resolve, and two exact same-name declarations in one file are ambiguous instead of snapping the first. Ambiguous candidates (same-file or cross-file) are that hit's span (signature, lines, text, context), not a clone of the first match's window. Span pick/slice live in `src/context/spans.js` for locate + host resolve.
20
+
21
+ - Do not subscribe a `context` observer when `seenWindow` is 0. A no-op listener still ran on every provider context event; opt-in retention windows still register.
22
+
23
+ - Encode the module BIND spine as a regression: acyclic imports, no upward edges, context and runtime stay siblings. host-bridge remains the fused INVOKE kernel.
24
+
25
+ - Drop guest/host RPC for `search` and `describe`, and stop returning unused `exec`/`patch`/`nova` bindings from the worker API. The guest still injects only read, write, edit, bash.
26
+
3
27
  ## [0.4.0] - 2026-09-10
4
28
 
5
29
  - Disable citation elision by default (`seenWindow: 0`), including direct bridge/ledger defaults. A failure-first integration regression preserves complete results across hidden, reordered and removed context. Positive windows remain an explicit research opt-in, not a validated retention guarantee. The historical ledger measurements below describe that experimental mode only.
@@ -69,30 +69,29 @@ text contributes to totals through later history, not as a second charge.
69
69
 
70
70
  ## Measured results
71
71
 
72
- Observed on macOS with Node v26.7.0 and Linux aarch64 with Node v24.16.0; both
73
- produced the same token counts.
72
+ Observed on macOS with Node v26.7.0.
74
73
 
75
74
  | Tokenizer | Non-batched baseline | Batched baseline (d444eb7) | Current | Further reduction | Total reduction |
76
75
  | --- | ---: | ---: | ---: | ---: | ---: |
77
- | o200k_base | 40,129 | 18,535 | 17,581 | **5.15%** | **56.19%** |
78
- | cl100k_base | 39,697 | 18,310 | 17,363 | **5.17%** | **56.26%** |
76
+ | o200k_base | 28,130 | 18,535 | 9,537 | **48.55%** | **66.10%** |
77
+ | cl100k_base | 27,841 | 18,310 | 9,420 | **48.55%** | **66.17%** |
79
78
 
80
79
  The gate requires at least 40% reduction on **each tokenizer for the complete
81
80
  workload**, not for every scenario individually. Token counts and reductions are
82
81
  computed from the recorded baseline and fresh execution results, not constants
83
- returned by the runtime. A second gate requires another 5% against the measured
82
+ returned by the runtime. A second gate requires another 19% against the measured
84
83
  batched baseline from commit d444eb7. Its six-call argument hash is pinned as well
85
84
  as the original workload: removing a decision boundary cannot satisfy this gate.
86
85
 
87
86
  ### What changed after the batched baseline
88
87
 
89
- The serialized definition falls from 1,068 to 947 tokens with o200k_base and from
90
- 1,057 to 937 with cl100k_base. Repeated guidance now has one model-visible home;
91
- the command reference, safety rules and schema constraints remain available.
92
- Batch framing declares UTF-16 length units once instead of on every entry and
93
- omits redundant aggregate mutation totals from the wrapper. Every original
94
- per-program result, including its mutation report, remains intact. Structured
95
- aggregate counters are unchanged. No source text or independent result is removed.
88
+ The serialized definition falls from 1,068 to 577 tokens with o200k_base and from
89
+ 1,057 to 572 with cl100k_base. Duplicate object-form restatements, parameter
90
+ prose already covered by the command list, and discoverable operational asides
91
+ were removed; command signatures and safety rules (`complete:true`, JSON 16 MiB /
92
+ no jq, array-read rejection, transactions, `programs` batch, edit oldText as an
93
+ exact substring, `edit(view,text)`) remain in the standing reference. No source
94
+ text or independent result is removed or compressed.
96
95
 
97
96
  For the fixed six-call schedule, the accounting can also be written as:
98
97
 
@@ -100,14 +99,17 @@ For the fixed six-call schedule, the accounting can also be written as:
100
99
  Total = (N+1)*D + sum((N-i+2)*A_i + (N-i+1)*R_i, i = 1..N)
101
100
  ~~~
102
101
 
103
- Seven definition appearances save 7*121 = 847 tokens with o200k_base. The smaller
104
- batch wrappers save another 107 after history replay, for 954/18,535 = 5.15%.
105
- For cl100k_base the corresponding saving is 7*120 + 107 = 947 tokens. The logical
106
- programs, their arguments, complete results and decision boundaries are unchanged.
107
-
108
- There are still costs: the current definition exceeds the non-batched baseline
109
- by 39/36 tokens per request, and batching adds result framing. One-off calls should
110
- not be assumed to benefit from the batch API.
102
+ Seven definition appearances still save 7*491 = 3,437 tokens with o200k_base versus
103
+ d444eb7's 1,068-token definition. Snap-to-span then changed the first repair
104
+ observation from a whole-file view to the `MAX_JSON_BYTES` declaration
105
+ (`lines:[3,3]`, `complete:false`). That smaller result is replayed through later
106
+ requests; no source or independent result is compressed or dropped. Frozen
107
+ programs, arguments and decision boundaries are unchanged. Combined with
108
+ batching, current o200k traffic is 9,558 vs d444eb7's 18,535 (48.43%).
109
+
110
+ The current definition is now *below* the non-batched baseline (577 vs 908
111
+ o200k_base). Batching still adds result framing. One-off calls should not be
112
+ assumed to benefit from the batch API.
111
113
 
112
114
  The report also includes separate source-framing and argument-reuse comparisons.
113
115
  Those component measurements are not total-session savings, and the reported
package/index.js CHANGED
@@ -4,7 +4,7 @@ import { REFERENCE } from "./src/runtime/reference.js";
4
4
  import { isString, isFunction } from "./src/shared/decode.js";
5
5
  import { loadConfig } from "./src/config/config.js";
6
6
  import { createHostBridge } from "./src/bridge/host-bridge.js";
7
- import { truncateChars } from "./src/output/format.js";
7
+ import { truncateChars, formatBoundedStringArray } from "./src/output/format.js";
8
8
  import { runGuestProgram, warmGuestWorker, stopWarmGuestWorker } from "./src/runtime/runtime.js";
9
9
  import { renderSupernovaCall, renderSupernovaResult } from "./src/ui/render.js";
10
10
 
@@ -99,6 +99,15 @@ function mutationText(outcome) {
99
99
  return "\nmutations: committed=" + m.committed + " rolledBack=" + m.rolledBack + " (file versions)" + external + uncertain;
100
100
  }
101
101
 
102
+ function mutationReceipts(trace) {
103
+ if (!Array.isArray(trace)) return "";
104
+
105
+ return trace
106
+ .filter(row => row?.ok && (row.name === "write" || row.name === "edit") && isString(row.resultText) && row.resultText)
107
+ .map(row => row.resultText)
108
+ .join("\n");
109
+ }
110
+
102
111
  // Corrective hint, emitted only when a turn actually split. Independent work
103
112
  // belongs in one program: a split cannot use the single prewarmed worker and pays
104
113
  // one extra spawn per sibling. Costs nothing until it fires, so it needs no room in
@@ -119,6 +128,25 @@ function successText(outcome, call) {
119
128
  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}`;
120
129
  }
121
130
 
131
+ function fitOutput(outcome, call, limit, format) {
132
+ let text = format(outcome, call);
133
+
134
+ if (text.length <= limit) return text;
135
+ outcome.returnTruncated = true;
136
+ const wrapper = format({ ...outcome, resultText: "", logs: [] }, call);
137
+ const room = Math.max(256, limit - wrapper.length);
138
+
139
+ if (Array.isArray(outcome.result) && outcome.result.length && outcome.result.every(isString)) {
140
+ outcome.resultText = formatBoundedStringArray(outcome.result, room);
141
+ } else if (isString(outcome.resultText) && outcome.resultText.length > room) {
142
+ outcome.resultText = truncateChars(outcome.resultText, room, "output").text;
143
+ }
144
+
145
+ text = format(outcome, call);
146
+
147
+ return text.length <= limit ? text : truncateChars(text, limit, "output").text;
148
+ }
149
+
122
150
  const TOOL_DESCRIPTION = REFERENCE;
123
151
 
124
152
  export default function piSupernova(pi) {
@@ -140,6 +168,9 @@ export function registerCodeMode(pi) {
140
168
  // Counting them lets a result say so without adding standing guidance to the
141
169
  // tool definition, which is resent on every request.
142
170
  let inFlight = 0;
171
+ // Peak concurrent execute() bodies in the current wave. Start-order or
172
+ // finish-order alone cannot see a first-started call that finishes last.
173
+ let overlapPeak = 0;
143
174
 
144
175
  function cancelWarmTimer() {
145
176
  if (warmTimer !== undefined) clearImmediate(warmTimer);
@@ -176,17 +207,17 @@ export function registerCodeMode(pi) {
176
207
  name: "supernova",
177
208
  label: "Supernova",
178
209
  description: TOOL_DESCRIPTION,
179
- promptSnippet: "JavaScript with read, write, edit, and bash",
210
+ promptSnippet: "read, write, edit, bash",
180
211
  parameters: Type.Object({
181
212
  code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
182
213
  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." })),
184
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
214
+ data: Type.Optional(Type.Unknown()),
215
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
185
216
  programs: Type.Optional(Type.Array(Type.Object({
186
217
  code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
187
218
  file: Type.Optional(Type.String({ minLength: 1 })),
188
219
  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."})),
220
+ }, {additionalProperties:false}), {minItems:1,maxItems:32})),
190
221
  }),
191
222
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
192
223
  // so separate call/result slots cannot duplicate the lifecycle card.
@@ -214,8 +245,9 @@ export function registerCodeMode(pi) {
214
245
  emitProgress([]);
215
246
  const started = performance.now();
216
247
  let outcome;
217
- const overlappedTurn = inFlight > 0 ? inFlight + 1 : 0;
218
248
  inFlight += 1;
249
+ overlapPeak = Math.max(overlapPeak, inFlight);
250
+ let peakSeen = overlapPeak;
219
251
 
220
252
  try {
221
253
  refreshCatalog(runBridge);
@@ -244,7 +276,9 @@ export function registerCodeMode(pi) {
244
276
  while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
245
277
  outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
246
278
  } finally {
279
+ peakSeen = Math.max(peakSeen, overlapPeak);
247
280
  inFlight -= 1;
281
+ if (inFlight === 0) overlapPeak = 0;
248
282
  runBridge.setCallListener(null);
249
283
  emitProgress.flush();
250
284
  signal?.removeEventListener("abort", abortRun);
@@ -264,21 +298,20 @@ export function registerCodeMode(pi) {
264
298
  }
265
299
 
266
300
  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);
301
+ outcome.overlappedTurn = peakSeen > 1 ? peakSeen : 0;
271
302
  outcome.mutations = runBridge.getMutations();
272
303
  const trace = runBridge.getTrace();
273
- const format = outcome.ok ? successText : errorText;
274
- let text = format(outcome, call);
275
304
 
276
- if (text.length > config.maxReturnChars) {
277
- outcome.returnTruncated = true;
278
- text = format(outcome, call);
279
- }
305
+ if (outcome.ok && outcome.result === undefined) {
306
+ const receipts = mutationReceipts(trace);
280
307
 
281
- const bounded = truncateChars(text, config.maxReturnChars, "output").text;
308
+ if (receipts) {
309
+ outcome.resultText = receipts;
310
+ outcome.undefinedReturn = false;
311
+ }
312
+ }
313
+ const format = outcome.ok ? successText : errorText;
314
+ const bounded = fitOutput(outcome, call, config.maxReturnChars, format);
282
315
  const visible = runBridge.ledger.dedupe(bounded, call);
283
316
 
284
317
  const response = result(visible, {
@@ -300,10 +333,13 @@ export function registerCodeMode(pi) {
300
333
  });
301
334
 
302
335
  // 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
- });
336
+ // Shipping seenWindow:0 must not subscribe: a no-op listener still runs on every
337
+ // provider context event. Opt-in windows register here.
338
+ if ((config.seenWindow ?? 0) > 0) {
339
+ pi.on("context", event => {
340
+ try { bridge.ledger.observe(event?.messages); } catch {}
341
+ });
342
+ }
307
343
 
308
344
  pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer();
309
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -3,10 +3,11 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
6
- import { isString, isNumber, isFunction, isObject } from "../shared/decode.js";
6
+ import { isString, isNumber, isFunction, isObject, looksLikePath } from "../shared/decode.js";
7
7
  import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
8
8
  import { unknownToolMessage } from "./catalog.js";
9
9
  import { extractStructuralSurface } from "../context/surface.js";
10
+ import { pickSpan } from "../context/spans.js";
10
11
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
11
12
  import { executeSnap } from "../context/snap.js";
12
13
  import { selectEvidence } from "../context/evidence.js";
@@ -18,7 +19,7 @@ import { declaredName } from "../context/repo-index.js";
18
19
  import { CausalVfs } from "../fs/vfs.js";
19
20
  import { MAX_JSON_BYTES, jsonProjector, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
20
21
  import { applyPatchToText } from "../fs/patch.js";
21
- import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
22
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash, assertFilesystemPath } from "../fs/workspace.js";
22
23
  import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
23
24
 
24
25
  function textResult(text, details) {
@@ -28,6 +29,20 @@ function textResult(text, details) {
28
29
  };
29
30
  }
30
31
 
32
+ function resultDiff(response) {
33
+ let details = response?.details;
34
+
35
+ if (isString(details)) {
36
+ try {
37
+ details = JSON.parse(details);
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ return isObject(details) ? details.diff : undefined;
44
+ }
45
+
31
46
  /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
32
47
  function unwrapIfFullyQuoted(s) {
33
48
  if (s.length < 2) return s;
@@ -52,19 +67,9 @@ function sliceLines(text, offset, limit) {
52
67
  return lines.slice(startIndex, startIndex + count).join("\n");
53
68
  }
54
69
 
55
- function looksLikePath(target) {
56
- return (
57
- isString(target) &&
58
- (target.includes("/") ||
59
- target.includes("\\") ||
60
- target.startsWith(".") ||
61
- (!/\s/.test(target) && path.extname(target).length > 0))
62
- );
63
- }
64
-
65
70
  function resolveReadPath(cwd, target) {
66
71
  if (!isString(target) || !target.trim()) throw new Error("read requires path");
67
- const input = target.trim();
72
+ const input = assertFilesystemPath(target, "read");
68
73
 
69
74
  return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
70
75
  }
@@ -87,6 +92,39 @@ async function probeExistingPath(cwd, targetParam, vfs) {
87
92
  }
88
93
  }
89
94
 
95
+ const EDIT_PREVIEW_LINES = 16;
96
+
97
+ function sourceLines(content) {
98
+ const raw = content.split("\n");
99
+
100
+ if (raw.at(-1) === "") raw.pop();
101
+
102
+ return raw;
103
+ }
104
+
105
+ function lineNumberAt(content, index) {
106
+ let line = 1;
107
+
108
+ for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
109
+
110
+ return line;
111
+ }
112
+
113
+ function formatNumberedLine(n, text) {
114
+ return String(n).padStart(5) + " " + text;
115
+ }
116
+
117
+ function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
118
+ const lines = sourceLines(content);
119
+
120
+ if (lines.length === 0) return "0 lines";
121
+ const shown = lines.slice(0, cap);
122
+ const body = shown.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
123
+ const suffix = lines.length > cap ? lines.length + " lines total" : lines.length + " lines";
124
+
125
+ return body + "\n" + suffix;
126
+ }
127
+
90
128
  function applyReplacements(target, content, requestedEdits) {
91
129
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
92
130
 
@@ -99,11 +137,16 @@ function applyReplacements(target, content, requestedEdits) {
99
137
  const index = content.indexOf(replacement.oldText);
100
138
 
101
139
  if (index < 0) {
102
- throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
140
+ throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
103
141
  }
142
+ const second = content.indexOf(replacement.oldText, index + 1);
104
143
 
105
- if (content.indexOf(replacement.oldText, index + 1) >= 0) {
106
- throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
144
+ if (second >= 0) {
145
+ const lines = sourceLines(content);
146
+ const a = lineNumberAt(content, index);
147
+ const b = lineNumberAt(content, second);
148
+
149
+ throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lines[a - 1] ?? "") + "\n" + formatNumberedLine(b, lines[b - 1] ?? ""));
107
150
  }
108
151
 
109
152
  return { ...replacement, index, end: index + replacement.oldText.length };
@@ -125,6 +168,27 @@ function applyReplacements(target, content, requestedEdits) {
125
168
  return { updated, matches };
126
169
  }
127
170
 
171
+ function applyViewReplace(target, content, start, end, oldText, newText) {
172
+ const current = sliceLines(content, start, end - start + 1);
173
+
174
+ if (current !== oldText) {
175
+ const shown = current.length ? current : content;
176
+
177
+ throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
178
+ }
179
+
180
+ const hadTrail = content.endsWith("\n");
181
+ const lines = content.split("\n");
182
+
183
+ if (hadTrail && lines.at(-1) === "") lines.pop();
184
+ const insert = newText.split("\n");
185
+
186
+ if (newText.endsWith("\n") && insert.at(-1) === "") insert.pop();
187
+ const updated = [...lines.slice(0, start - 1), ...insert, ...lines.slice(end)].join("\n") + (hadTrail ? "\n" : "");
188
+
189
+ return { updated, oldText, newText };
190
+ }
191
+
128
192
  function formatDirectoryEntry(name, type, size = 0) {
129
193
  const sizeSuffix = size ? `, ${size} bytes` : "";
130
194
 
@@ -151,6 +215,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
151
215
  const reads = createNativeScheduler();
152
216
 
153
217
  async function sourceRead(query, searchDir, signal, params = {}) {
218
+ params = { ...params, resolve: params.resolve !== false };
154
219
  const cwd = getCwd();
155
220
 
156
221
  const includeHidden = path.relative(cwd, searchDir).split(path.sep)
@@ -160,15 +225,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
160
225
  pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
161
226
  overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
162
227
 
163
- return openSource(result, params, signal);
228
+ return openSource(result, params, signal, undefined, query);
164
229
  }
165
230
 
166
- async function openSource(result, params, signal, resolvedPath) {
231
+ async function openSource(result, params, signal, resolvedPath, query) {
167
232
  const cwd = getCwd();
168
233
 
169
234
  if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
170
235
  signal?.throwIfAborted();
171
- const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path);
236
+ const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path, query);
172
237
  const block = opened.content[0];
173
238
 
174
239
  if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
@@ -318,7 +383,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
318
383
  }
319
384
 
320
385
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
321
- async function readFile(targetPath, params, sourceLine, displayPath) {
386
+ async function readFile(targetPath, params, sourceLine, displayPath, query) {
322
387
  const cwd = getCwd();
323
388
  const rel = displayPath ?? relativeSlash(cwd, targetPath);
324
389
 
@@ -371,9 +436,22 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
371
436
 
372
437
  const explicit = isNumber(params?.offset) || isNumber(params?.limit);
373
438
  const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
374
- const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
439
+ let offset = params?.offset;
440
+ let limit = params?.limit;
441
+
442
+ if (!explicit && params.resolve) {
443
+ const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(targetPath, text));
444
+ const span = pickSpan(spans, { line: sourceLine, name: query });
445
+
446
+ if (span) {
447
+ offset = span.start;
448
+ limit = span.end - span.start + 1;
449
+ }
450
+ }
451
+
452
+ offset ??= sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1;
375
453
  const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
376
- const sliced = sliceLines(text, offset, params?.limit);
454
+ const sliced = sliceLines(text, offset, limit);
377
455
 
378
456
  if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
379
457
  throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
@@ -416,19 +494,23 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
416
494
  * lines with numbers, a quick structural check, and bounded lexical reference hints.
417
495
  * These do not replace tests or semantic caller resolution.
418
496
  */
419
- async function editSummary(cwd, target, original, updated, diff, signal) {
497
+ async function editSummary(cwd, target, original, updated, diff, signal, span) {
420
498
  const rel = relativeSlash(cwd, target);
421
499
  const newLines = updated.split("\n");
422
500
  const ranges = [];
423
501
 
424
- const positions = diff.lines.filter(row => row.type !== "context")
425
- .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
502
+ if (span && Number.isInteger(span.start) && Number.isInteger(span.end) && span.start >= 1 && span.end >= span.start) {
503
+ ranges.push({ start: span.start, end: Math.min(newLines.length, span.end) });
504
+ } else {
505
+ const positions = diff.lines.filter(row => row.type !== "context")
506
+ .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
426
507
 
427
- for (const line of positions) {
428
- const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
508
+ for (const line of positions) {
509
+ const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
429
510
 
430
- if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
431
- else ranges.push({ start, end });
511
+ if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
512
+ else ranges.push({ start, end });
513
+ }
432
514
  }
433
515
 
434
516
  const blocks = [];
@@ -625,11 +707,28 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
625
707
 
626
708
  if (signal?.aborted) throw new Error("aborted");
627
709
 
710
+ const content = await vfs.read(target);
711
+
712
+ if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
713
+ const windowNext = isString(params.oldText)
714
+ ? applyReplacements(target, params.viewText, [{ oldText: params.oldText, newText: params.newText }]).updated
715
+ : params.newText;
716
+ const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, params.viewText, windowNext);
717
+ const { speculative } = await vfs.write(target, updated);
718
+ index.touch(relativeSlash(cwd, target));
719
+ const diffFrom = isString(params.oldText) ? params.oldText : params.viewText;
720
+ const diffTo = isString(params.oldText) ? params.newText : windowNext;
721
+ const diff = buildEditDiff(target, content, diffFrom, diffTo);
722
+ const inserted = sourceLines(windowNext);
723
+ const spanEnd = params.viewStart + Math.max(inserted.length, 1) - 1;
724
+ const summary = await editSummary(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
725
+
726
+ return textResult(summary, { path: target, speculative, diff });
727
+ }
728
+
628
729
  const requestedEdits = Array.isArray(params?.edits)
629
730
  ? params.edits
630
731
  : [{ oldText: params?.oldText, newText: params?.newText }];
631
-
632
- const content = await vfs.read(target);
633
732
  const { updated, matches } = applyReplacements(target, content, requestedEdits);
634
733
  const { speculative } = await vfs.write(target, updated);
635
734
  index.touch(relativeSlash(cwd, target));
@@ -733,7 +832,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
733
832
  },
734
833
  async bash(params, signal) {
735
834
  const cwd = getCwd();
736
- const literal = params?._directArgv === true;
835
+ const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
737
836
 
738
837
  if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
739
838
  const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
@@ -850,6 +949,11 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
850
949
  };
851
950
  }
852
951
 
952
+ /**
953
+ * Fused INVOKE kernel. Guest RPC is the only caller; fuel is cwd + vfs + signal.
954
+ * BIND stays downward (see tests/contracts/layers.test.mjs). Do not split this
955
+ * closure into pass-through files that re-import each other.
956
+ */
853
957
  export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
854
958
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
855
959
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
@@ -1018,20 +1122,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1018
1122
  return vfs.rollback();
1019
1123
  }
1020
1124
 
1021
- function resultDiff(response) {
1022
- let details = response?.details;
1023
-
1024
- if (isString(details)) {
1025
- try {
1026
- details = JSON.parse(details);
1027
- } catch {
1028
- return undefined;
1029
- }
1030
- }
1031
-
1032
- return isObject(details) ? details.diff : undefined;
1033
- }
1034
-
1035
1125
  function notifyCall(record) {
1036
1126
  if (!callListener) return;
1037
1127
 
@@ -1106,8 +1196,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1106
1196
  try {
1107
1197
  const delegated = hostTool(name);
1108
1198
  const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
1199
+ const argvOwned = name === "bash" && Array.isArray(args?.args) && process.platform !== "win32" && args.args.length === Object.keys(args.args).length && args.args.every(isString);
1109
1200
 
1110
- if (exec) {
1201
+ if (exec && !argvOwned) {
1111
1202
  if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
1112
1203
 
1113
1204
  if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
@@ -1158,6 +1249,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1158
1249
  const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
1159
1250
 
1160
1251
  if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
1252
+ const text = isObject(res) && Array.isArray(res.content)
1253
+ ? res.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n")
1254
+ : undefined;
1255
+
1256
+ if (text) record.resultText = text;
1161
1257
  }
1162
1258
 
1163
1259
  async function call(name, args) {
@@ -96,6 +96,42 @@ export function globToRegExp(glob) {
96
96
  return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
97
97
  }
98
98
 
99
+ function declarationEnd(raw, lower, start, lineCount, ext) {
100
+ if (ext === ".py") {
101
+ const indentOf = (i) => raw[i].length - raw[i].trimStart().length;
102
+ const base = indentOf(start - 1);
103
+ let end = start;
104
+
105
+ for (let i = start; i < lineCount; i++) {
106
+ if (lower[i] === "") { end = i + 1; continue; }
107
+ if (indentOf(i) <= base) break;
108
+ end = i + 1;
109
+ }
110
+
111
+ return Math.min(end, lineCount);
112
+ }
113
+
114
+ let depth = 0;
115
+
116
+ for (const ch of raw[start - 1] ?? "") {
117
+ if (ch === "{") depth++;
118
+ else if (ch === "}") depth--;
119
+ }
120
+
121
+ if (depth <= 0) return start;
122
+
123
+ for (let i = start; i < raw.length; i++) {
124
+ for (const ch of raw[i]) {
125
+ if (ch === "{") depth++;
126
+ else if (ch === "}") depth--;
127
+ }
128
+
129
+ if (depth <= 0) return i + 1;
130
+ }
131
+
132
+ return lineCount;
133
+ }
134
+
99
135
  export class WorkspaceIndex {
100
136
  constructor(runCommand) {
101
137
  this.runCommand = runCommand;
@@ -274,18 +310,18 @@ export class WorkspaceIndex {
274
310
  }
275
311
 
276
312
  /**
277
- * Declaration spans [start, end] (1-based, inclusive) in file order, trailing blank lines trimmed.
278
- * A span runs to the line before the next declaration; the file's leading header is not a span.
313
+ * Declaration spans [start, end] (1-based, inclusive). Nested bodies stay inside the parent
314
+ * (brace-matched for JS-like, indent for Python). The file's leading header is not a span.
279
315
  */
280
316
  static spansOf(entry) {
281
317
  if (entry.spans) return entry.spans;
282
318
  const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
283
- const { lower } = WorkspaceIndex.linesOf(entry);
319
+ const { lower, raw } = WorkspaceIndex.linesOf(entry);
284
320
  const spans = [];
285
321
 
286
322
  for (let i = 0; i < items.length; i++) {
287
323
  const start = items[i].line;
288
- let end = Math.min(i + 1 < items.length ? items[i + 1].line - 1 : lineCount, lineCount);
324
+ let end = declarationEnd(raw, lower, start, lineCount, entry.ext);
289
325
 
290
326
  while (end > start && lower[end - 1] === "") end--;
291
327
  spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
@@ -3,6 +3,8 @@ import { isString } from "../shared/decode.js";
3
3
  import { truncateChars } from "../output/format.js";
4
4
  import * as fs from "node:fs/promises";
5
5
  import { extractStructuralSurface } from "./surface.js";
6
+ import { WorkspaceIndex } from "./repo-index.js";
7
+ import { pickSpan, spanCandidate, spanWindow } from "./spans.js";
6
8
  import { rankPaths } from "./fuzzy.js";
7
9
  import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
8
10
 
@@ -86,7 +88,7 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
86
88
  return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
87
89
  pathCoverage: tokens.filter(token => lower.includes(token)).length,
88
90
  matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
89
- line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
91
+ line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
90
92
  }
91
93
 
92
94
  function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
@@ -115,6 +117,8 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
115
117
 
116
118
  const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
117
119
 
120
+ if (exact) candidate.exactLines.add(lineNumber);
121
+
118
122
  if (score > candidate.anchorScore) {
119
123
  candidate.anchorScore = score;
120
124
  candidate.line = lineNumber;
@@ -220,6 +224,31 @@ function location(candidate, root) {
220
224
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
221
225
  }
222
226
 
227
+ async function spanCandidates(filePath, lines, root, overlayText) {
228
+ const staged = overlayText(filePath);
229
+ const text = staged !== undefined ? staged : await fs.readFile(filePath, "utf8");
230
+ const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
231
+ const rel = path.relative(root, filePath);
232
+
233
+ return lines.map(line => {
234
+ const span = pickSpan(spans, { line }) ?? { start: line, end: line };
235
+
236
+ return spanCandidate(rel, line, spanWindow(text, span.start, span.end));
237
+ });
238
+ }
239
+
240
+ async function rankedSpanCandidates(ranked, root, overlayText) {
241
+ const out = [];
242
+
243
+ for (const candidate of ranked) {
244
+ const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
245
+ out.push(...await spanCandidates(candidate.path, lines, root, overlayText));
246
+ if (out.length >= MAX_ALTERNATIVES) break;
247
+ }
248
+
249
+ return out.slice(0, MAX_ALTERNATIVES);
250
+ }
251
+
223
252
  export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
224
253
  const flags = tokenizeQuery(query);
225
254
  const tokens = [...new Set(flags.tokens.map(stem))];
@@ -306,7 +335,14 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
306
335
  const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
307
336
  const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
308
337
 
309
- if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
338
+ if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
339
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText) };
340
+ }
341
+
342
+ if (best.exactLines.size > 1) {
343
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText) };
344
+ }
345
+
310
346
  const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
311
347
 
312
348
  return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
@@ -0,0 +1,39 @@
1
+ import { truncateChars } from "../output/format.js";
2
+
3
+ export function pickSpan(spans, { line, name } = {}) {
4
+ const needle = typeof name === "string" && /^[A-Za-z_$][\w$]*$/.test(name.trim()) ? name.trim().toLowerCase() : "";
5
+ const named = needle ? spans.filter(item => item.name.toLowerCase() === needle) : [];
6
+
7
+ if (named.length === 1) return named[0];
8
+ if (!line) return;
9
+
10
+ return spans.find(item => item.start === line)
11
+ ?? spans.filter(item => item.start <= line && line <= item.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
12
+ }
13
+
14
+ export function spanWindow(text, start, end) {
15
+ const raw = text.split("\n");
16
+
17
+ return {
18
+ start,
19
+ end,
20
+ text: raw.slice(start - 1, end).join("\n"),
21
+ signature: truncateChars((raw[start - 1] ?? "").trim().replace(/\{.*$/, "").trim(), 240, "signature").text,
22
+ context: Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => {
23
+ const n = start + i;
24
+
25
+ return { line: n, text: raw[n - 1] ?? "" };
26
+ }),
27
+ };
28
+ }
29
+
30
+ export function spanCandidate(relPath, line, window) {
31
+ return {
32
+ path: relPath,
33
+ line,
34
+ lines: [window.start, window.end],
35
+ text: window.text,
36
+ signature: window.signature,
37
+ context: window.context.map(row => (row.line === line ? "►" : " ") + row.line + " " + row.text),
38
+ };
39
+ }
@@ -81,10 +81,10 @@ const JS_DECL_PATTERNS = [
81
81
  // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
82
82
  const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
83
83
 
84
- // Indented methods (object-literal adapters, class members) that open a block on the same line.
85
- const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
84
+ // Indented methods (object-literal adapters, class members), including one-liners.
85
+ const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{/;
86
86
 
87
- const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
87
+ const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{/;
88
88
 
89
89
  const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
90
90
 
@@ -93,16 +93,18 @@ function methodItem(line, lineNumber, depth) {
93
93
 
94
94
  if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
95
95
 
96
- return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
96
+ return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{.*$/, "").trim(), line: lineNumber, depth };
97
97
  }
98
98
 
99
99
  function declarationItem(line, rawLine, lineNumber) {
100
+ const indent = rawLine.length - rawLine.trimStart().length;
101
+ const depth = Math.floor(indent / 2);
100
102
  const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
101
103
 
102
104
  for (const [pattern, isExport] of patterns) {
103
105
  const match = pattern.exec(line);
104
106
 
105
- if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
107
+ if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth };
106
108
  }
107
109
 
108
110
  return null;
@@ -116,7 +118,7 @@ function scanJavaScript(lines) {
116
118
 
117
119
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
118
120
  const indent = lines[i].length - lines[i].trimStart().length;
119
- const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
121
+ const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
120
122
 
121
123
  if (item) items.push(item);
122
124
  }
@@ -43,7 +43,11 @@ export function jsonProjector(json) {
43
43
  return function* (root) {
44
44
  for (const steps of plans) yield steps.reduce((value, step) => {
45
45
  if (step.key !== undefined) {
46
- if (!isObject(value) || !Object.hasOwn(value, step.key)) throw new Error("JSON field not found: " + JSON.stringify(step.key));
46
+ if (!isObject(value) || !Object.hasOwn(value, step.key)) {
47
+ const keys = isObject(value) ? Object.keys(value) : [];
48
+ const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
49
+ throw new Error("JSON field not found: " + JSON.stringify(step.key) + preview);
50
+ }
47
51
 
48
52
  return value[step.key];
49
53
  }
package/src/fs/vfs.js CHANGED
@@ -11,6 +11,9 @@ let commitTail = Promise.resolve();
11
11
  export class CausalVfs {
12
12
  constructor(onNewFile, validateWrite) {
13
13
  this.validateWrite = validateWrite;
14
+ // Last-seen original bytes for write CAS, not a read cache. read() always
15
+ // hits disk unless an overlay is staged. Serving cache on read would be a
16
+ // false-valid against editors/git between two reads.
14
17
  this.cache = new Map();
15
18
  this.overlays = [];
16
19
  this.expected = new Map();
@@ -64,14 +64,38 @@ export function isTestPath(filePath) {
64
64
  return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
65
65
  }
66
66
 
67
- export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
67
+ /** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
68
+ export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
68
69
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
69
70
  throw new Error(`${opName} requires path`);
70
71
  }
71
72
 
72
- if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
73
+ const trimmed = inputPath.trim();
74
+
75
+ if (/^(?:agent|artifact):\/\//i.test(trimmed)) {
76
+ if (allowSessionRead) return trimmed;
77
+ throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
78
+ }
79
+
80
+ const uri = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/.exec(trimmed);
81
+
82
+ if (uri) {
83
+ const scheme = uri[1];
84
+ const rest = uri[2];
85
+ const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
86
+
87
+ if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
88
+ throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
89
+ }
90
+ }
91
+
92
+ return trimmed;
93
+ }
94
+
95
+ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
96
+ const trimmed = assertFilesystemPath(inputPath, opName);
73
97
  const resolvedCwd = getResolvedCwd(cwd);
74
- const target = path.resolve(resolvedCwd, inputPath.trim());
98
+ const target = path.resolve(resolvedCwd, trimmed);
75
99
  assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
76
100
 
77
101
  if (!allowRoot && target === resolvedCwd) {
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { isString, isObject } from "../shared/decode.js";
5
- import { truncateChars, formatReturn } from "./format.js";
5
+ import { truncateChars, formatReturn, formatBoundedStringArray } from "./format.js";
6
6
 
7
7
  function json(value) {
8
8
  try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
@@ -152,12 +152,19 @@ export function packageHostResult(raw, config) {
152
152
  export function packageFinalReturn(value, logs, config) {
153
153
  const images = [];
154
154
  let imageBytes = 0;
155
+ let imageOverflow = false;
155
156
 
156
157
  const collect = input => {
157
158
  if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
158
- imageBytes += Buffer.byteLength(input.data, "base64");
159
+ const size = Buffer.byteLength(input.data, "base64");
159
160
 
160
- if (images.length >= 16 || imageBytes > 20 * 1024 * 1024) throw new Error("returned images exceed 16 attachments or 20 MiB; return fewer or smaller images");
161
+ if (images.length >= 16 || imageBytes + size > 20 * 1024 * 1024) {
162
+ imageOverflow = true;
163
+
164
+ return "[image omitted: exceeds 16 attachments or 20 MiB]";
165
+ }
166
+
167
+ imageBytes += size;
161
168
  images.push({ type: "image", data: input.data, mimeType: input.mimeType });
162
169
 
163
170
  return `[image ${images.length}: ${input.mimeType}]`;
@@ -171,7 +178,13 @@ export function packageFinalReturn(value, logs, config) {
171
178
  };
172
179
 
173
180
  value = collect(value);
174
- const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
181
+ const maxReturn = config.maxReturnChars ?? 32000;
182
+ const formatted = formatReturn(value);
183
+ const serialized = formatted.length <= maxReturn
184
+ ? { text: formatted, truncated: imageOverflow }
185
+ : Array.isArray(value) && value.length && value.every(isString)
186
+ ? { text: formatBoundedStringArray(value, maxReturn), truncated: true }
187
+ : { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
175
188
  const maxLines = config.maxLogLines ?? 100;
176
189
  let logTruncated = logs.length > maxLines;
177
190
 
@@ -71,6 +71,25 @@ function hasWellFormedStrings(values) {
71
71
  return true;
72
72
  }
73
73
 
74
+ /** Keep every array item in an oversized return by giving each a fair truncated share. */
75
+ export function formatBoundedStringArray(values, budget) {
76
+ const n = values.length;
77
+ const header = "strings[" + n + "]\n";
78
+ let remaining = Math.max(0, budget - header.length);
79
+ let out = header;
80
+
81
+ for (let i = 0; i < n; i++) {
82
+ const itemHeader = "[" + i + "] " + values[i].length + " UTF-16 units\n";
83
+ const per = Math.max(32, Math.floor(remaining / (n - i)) - itemHeader.length - 1);
84
+ const bounded = truncateChars(values[i], per, "return");
85
+ const chunk = itemHeader + bounded.text + "\n";
86
+ remaining = Math.max(0, remaining - chunk.length);
87
+ out += chunk;
88
+ }
89
+
90
+ return out;
91
+ }
92
+
74
93
  /** Lossless framing for source arrays, not string escaping or source compression. */
75
94
  export function formatReturn(value) {
76
95
  if (isString(value)) return value;
@@ -1,6 +1,6 @@
1
1
  import { parentPort } from "node:worker_threads";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
3
+ import { isString, isObject, isFunction, isNumber, toPlain, looksLikePath } from "../shared/decode.js";
4
4
  import { truncateChars } from "../output/format.js";
5
5
  import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
6
6
 
@@ -94,7 +94,11 @@ function leanEnvelope(res) {
94
94
  return res;
95
95
  }
96
96
 
97
- function buildGuestApi(available, batchRead, runId, nativeArgv) {
97
+ function quoteShellArg(value) {
98
+ return "'" + String(value).replaceAll("'", "'\\''") + "'";
99
+ }
100
+
101
+ function buildGuestApi(available, batchRead, runId, _nativeArgv) {
98
102
  const rpc = (method, args) => callRpc(runId, method, args);
99
103
  const availableSet = new Set(available);
100
104
  const checkpointScope = new AsyncLocalStorage();
@@ -107,8 +111,6 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
107
111
  };
108
112
 
109
113
  const nova = {
110
- search: (query, limit) => rpc("search", [query, limit]),
111
- describe: (name) => rpc("describe", [name]),
112
114
  call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
113
115
  async callMany(calls) {
114
116
  const wave = await rpc("callMany", [calls]);
@@ -191,6 +193,9 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
191
193
  const read = async (p, a, b) => {
192
194
  assertScope();
193
195
  const args = sessionJsonArgs(readArgs(p, a, b));
196
+ if (isString(args.path) && args.resolve === undefined && args.json === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
197
+ args.resolve = true;
198
+ }
194
199
  validateJsonRead(args);
195
200
  const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
196
201
 
@@ -241,10 +246,31 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
241
246
 
242
247
  const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
243
248
 
249
+ const viewSpan = (value) => {
250
+ const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
251
+ const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
252
+
253
+ if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
254
+
255
+ return { start: Math.floor(start), end: Math.floor(end) };
256
+ };
257
+
258
+ const isView = (value) => isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
259
+
244
260
  const edit = async (p, oldText, newText) => {
245
261
  if (isFunction(p)) return nova.speculate(p);
246
262
  const usage = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
247
263
 
264
+ if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
265
+ if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
266
+ const span = viewSpan(p);
267
+ const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
268
+
269
+ if (newText !== undefined) args.oldText = oldText;
270
+
271
+ return unwrapValue(await invoke("edit", args));
272
+ }
273
+
248
274
  if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
249
275
 
250
276
  if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
@@ -268,19 +294,21 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
268
294
  return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
269
295
  };
270
296
 
271
- const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
272
-
273
297
  const bash = async (command, opts) => {
274
298
  const args = isObject(command) ? { ...command } : { command, ...opts };
275
299
 
276
300
  if (args.args !== undefined) {
277
- if (!isString(args.command) || !Array.isArray(args.args) || args.args.some(arg => !isString(arg))) throw new Error("bash argv requires a command string and an array of string args");
301
+ if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
278
302
 
279
- if (nativeArgv) args._directArgv = true;
280
- else {
303
+ for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error("bash argv requires a command string and an array of string args");
304
+
305
+ // Literal argv is a Supernova-owned contract. Host bash tools often ignore
306
+ // `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
307
+ if (process.platform === "win32") {
281
308
  delete args._directArgv;
282
309
  args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
283
- }
310
+ delete args.args;
311
+ } else args._directArgv = true;
284
312
  }
285
313
 
286
314
  command = args.command;
@@ -307,20 +335,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
307
335
  return text;
308
336
  };
309
337
 
310
- const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
311
-
312
- const exec = async (cmd, args, opts) => {
313
- const command = String(cmd ?? "").trim();
314
-
315
- if (!command) throw new Error("exec requires command");
316
-
317
- // exec("git status") is a shell line; exec("git", ["status"]) is argv.
318
- if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
319
-
320
- return bash([command, ...args].map(quoteShellArg).join(" "), opts);
321
- };
322
-
323
- return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
338
+ return { read, write, edit, bash };
324
339
  }
325
340
 
326
341
  function makeConsole(runId, limits) {
@@ -1,25 +1,18 @@
1
1
  // Complete model-facing API reference; kept in every request, not moved into history.
2
- export const REFERENCE = `Run a JavaScript async body or arrow with read, write, edit and bash. Strings stay raw. Single program: exactly one of code or file; file rereads a workspace program with the same limits, bindings, calling-workspace cwd and fresh guest. Code and data character caps use UTF-16 units. Split large writes using append.
2
+ export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. Strings stay raw. Exactly one of code or file; file rereads that program (same limits, cwd, fresh guest). Caps are UTF-16. Put Markdown/scripts/argv in data.
3
3
 
4
- Native commands (async):
5
- read(path|paths, offset?, limit?) → file text or text[]; read(directory) → directory entries
6
- read(imagePath) → image attachment when returned (PNG/JPEG/GIF/WebP/BMP); no browser needed
7
- read({path,json:".field"}) → parsed JSON field; supports .items[0:3], quoted keys, selector arrays, or true for the whole JSON value
8
- read("agent://id?q=.answer") → JSON field from a calling-session resource (when host artifacts are available)
9
- read("symbol or question") → locate and open source in one call, without an index; selected file text stays raw
10
- read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
11
- read(path, {about: question}) → relevant file bodies, or source selection inside a directory
12
- read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
13
- write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
14
- edit(path, oldText, newText) → post-edit lines, checks, and references
15
- edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
16
- bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
17
- bash({command, args:[...]}) → literal argv without shell expansion of arguments
4
+ read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries
5
+ read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP)
6
+ read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, selector arrays, or true; 16 MiB cap; no jq
7
+ read("agent://id?q=.answer") → JSON field from session artifacts
8
+ read("symbol or question") → same view as resolve:true
9
+ read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
10
+ read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
11
+ write(path, text) → replace; write({path,content,append:true}) → append without a prior read
12
+ edit(path,oldText,newText) | edit({path,edits}) | edit({path,patch}) → numbered post-edit lines, checks, references
13
+ edit(async () => {...}) → checkpoint: commit on success, rollback on throw; no shell, nesting, or outside commands
14
+ bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
15
+ bash({command,args}) → literal argv, no shell expansion of args
18
16
 
19
- Start with a source question or scoped about read; do not redundantly reopen selected source. Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true to check status before editing its path; narrow the directory with path+about when uncertain.
20
- For read-modify-write, use read({path,complete:true}); it rejects partial output. JSON selectors parse the full document (16 MiB cap); selections must fit the read budget. No full jq; do not JSON.parse line windows. For large text audits use about or offset/limit, not complete:true. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
21
- 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?}).
22
- File changes stage until program success; later errors roll them back. Shell calls commit preceding writes and cannot be rolled back. Outcomes report committed/rolledBack file versions and external-call attempts.
23
- 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.
24
-
25
- For known continuations use programs:[{code|file,data?},...]. Entries run sequentially in fresh guests with separate commits. The batch stops on failure and returns all attempted results, including a typed stop report; earlier commits remain. Deadlines, host calls, logs and output are shared across the batch. Use separate calls when the next action needs model reasoning.`;
17
+ edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file; uncertain returns ambiguous, not_found, or incomplete. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files; use about or offset/limit for large audits. Array reads reject failures; Promise.allSettled for per-path outcomes. Edits stage until success; bash commits preceding writes.
18
+ programs:[{code|file,data?},...] sequential fresh guests, separate commits; stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
@@ -154,8 +154,6 @@ const RPC_METHODS = {
154
154
 
155
155
  return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
156
156
  },
157
- search: (nova, args) => nova.search(args[0], args[1]),
158
- describe: (nova, args) => nova.describe(args[0]),
159
157
  speculateBegin: (nova) => nova.speculateBegin(),
160
158
  speculateCommit: (nova) => nova.speculateCommit(),
161
159
  speculateRollback: (nova) => nova.speculateRollback(),
@@ -8,6 +8,17 @@ export const isFunction = (v) => toStr.call(v) === "[object Function]" || v inst
8
8
 
9
9
  export const isNumber = (v) => toStr.call(v) === "[object Number]" && Number.isFinite(v);
10
10
 
11
+ /** Path-shaped if it has a slash, is relative, or has a file extension. Identifiers are not paths. */
12
+ export function looksLikePath(target) {
13
+ return (
14
+ isString(target) &&
15
+ (target.includes("/") ||
16
+ target.includes("\\") ||
17
+ target.startsWith(".") ||
18
+ (!/\s/.test(target) && /\.[A-Za-z0-9]+$/.test(target)))
19
+ );
20
+ }
21
+
11
22
  const MAX_DEPTH = 64;
12
23
 
13
24
  const MAX_TYPED_ARRAY = 4096;