pi-supernova 0.7.0 → 0.8.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
@@ -14,6 +14,19 @@ transactional file operations, batching, bounded results and the grouped nova UI
14
14
 
15
15
  ## Unreleased
16
16
 
17
+ - **Shared program source:** top-level `code` or `file` supplies a batch default;
18
+ entries may override it. No temporary program file is required for inline reuse.
19
+ - **Explicit object defaults:** `mergeData:true` shallowly overlays per-entry data
20
+ onto common data. Existing whole-input replacement remains the default.
21
+
22
+ - Failed `edit` checkpoints now roll back and **throw**. Catch explicitly when
23
+ rejecting a candidate is intentional; ignored failures no longer report success.
24
+ - Read errors state both size limits and executable recovery examples. Markdown
25
+ edits skip code-reference searches; exact-symbol usage evidence excludes generic
26
+ matches and keeps late references inside the returned window.
27
+ - Returned image sets over 16 images / 20 MiB fail with aggregate counts and bytes,
28
+ rather than silently omitting attachments. Pending file changes roll back.
29
+
17
30
  - **`parallel: true` on `programs`:** independent entries run at once (up to 8),
18
31
  keep result order, and do not stop siblings on failure. Sequential is still
19
32
  the default.
@@ -162,6 +175,13 @@ lines. Structural warnings and source windows are not substitutes for tests.
162
175
 
163
176
  ### Safe read-modify-write
164
177
 
178
+ Path-only `read(path)` requires at most **160 lines and 8192 UTF-16 characters**.
179
+ A short document can exceed the character limit. For larger files use
180
+ `read(path,{offset:1,limit:80})` (one-based lines), `read(path,{about:"keywords"})`,
181
+ or `read(path,{complete:true})`. The default raw-text read budget is **31,744
182
+ characters**, derived from the configured call/return budgets, not an unlimited
183
+ full-file buffer. Large JSONL needs a bounded parser through `bash({command,args})`.
184
+
165
185
  Plain reads are bounded views, not guaranteed full-file buffers. Use
166
186
  `read({path:"file.txt",complete:true})` when code needs the complete file; it
167
187
  throws rather than handing back partial text. Prefer `edit` for large-file
@@ -252,12 +272,21 @@ for short one-off operations. See [token measurements](https://github.com/Aditya
252
272
  }
253
273
  ~~~
254
274
 
255
- Use programs instead of top-level code/file. Supply 1--32 entries, each with
256
- code OR file and optional data. Top-level data supplies an optional default for
257
- each entry; explicit entry data replaces it entirely, including null, false, 0
258
- and empty strings. Every guest receives its own copy, not a shared mutable heap.
259
- The JSON-encoded array (or `{programs,data}` when defaults are supplied) must fit
260
- maxCodeChars. Common input counts once; result representations and output limits are unchanged.
275
+ Supply 1--32 `programs` entries. Top-level `code` OR `file` supplies an optional
276
+ source default; an entry's own `code` OR `file` replaces it. Without a shared
277
+ source, every entry still requires its own. A shared file is reread when each
278
+ entry runs, so earlier commits can create or update it.
279
+
280
+ Top-level `data` supplies an optional input default. By default, explicit entry
281
+ `data` replaces it entirely, including null, false, 0 and empty strings. With
282
+ **`mergeData:true`**, both the default and every explicit entry input must be
283
+ objects: own entry fields override default fields in a **shallow** merge. Nested
284
+ objects are replaced, not recursively merged. Each guest receives its own copy;
285
+ mutations cannot leak between guests or back into caller-owned inputs.
286
+
287
+ The JSON-encoded array plus any shared code/file/data must fit `maxCodeChars`.
288
+ Common source/input counts once, before expansion. Every entry is validated
289
+ before any program runs. Result representations and output limits are unchanged.
261
290
  Entries run sequentially in fresh guests and commit separately. A successful
262
291
  entry can create the file executed by a later entry. No implicit retries,
263
292
  reordering, shared heap or nested batches are introduced.
@@ -278,6 +307,26 @@ This avoids repeating literal arguments, without a compression codec or result e
278
307
  Mutating `data` in one guest cannot affect the next. An entry with `data:null`
279
308
  receives null, not the shared object; there is no implicit object merge.
280
309
 
310
+ For the same program with varying inputs, no temporary script or input file is
311
+ needed. For example, audit both files with a common term:
312
+
313
+ ```json
314
+ {
315
+ "code": "const text=await read({path:data.path,complete:true}); return {path:data.path,found:text.includes(data.term),text};",
316
+ "data": {"term":"TODO"},
317
+ "mergeData": true,
318
+ "programs": [
319
+ {"data":{"path":"src/a.js"}},
320
+ {"data":{"path":"src/b.js"}}
321
+ ]
322
+ }
323
+ ```
324
+
325
+ The complete source is still returned twice, once for each requested file; shared
326
+ arguments do not authorize result deduplication or context rewriting. Existing
327
+ programs that depend on whole-input replacement keep that behavior unless the
328
+ caller explicitly requests `mergeData:true`.
329
+
281
330
  The batch stops on the first failed entry, cancellation/deadline, or exhausted
282
331
  output/log/image budget. Earlier successful commits remain; only the active
283
332
  program's uncommitted writes roll back. Admission errors throw before any program.
@@ -290,7 +339,7 @@ Set `parallel: true` with `programs` to run independent entries concurrently
290
339
  (up to 8 at once). Each still gets a fresh guest and its own commit; results stay
291
340
  in submission order. A failed entry does not stop siblings. Two entries writing
292
341
  the same file race: the losing commit reports a conflict. Sequential remains the
293
- default. `parallel` is invalid on a lone `code` or `file` call.
342
+ default. `parallel` and `mergeData` are invalid on a lone `code` or `file` call.
294
343
 
295
344
  The outer deadline, host-call budget, log allowance, text budget and image limits
296
345
  are shared across the batch. Individual read budgets are not reduced. Every
@@ -306,7 +355,10 @@ settings, hide observations, or infer a plan on the agent's behalf.
306
355
  ### Large inputs and report outputs
307
356
 
308
357
  The default program limit is 48,000 UTF-16 code units (configurable via
309
- `maxCodeChars` and exposed in the tool schema). Split larger documents into
358
+ `maxCodeChars` and exposed in the tool schema). The same cap applies separately
359
+ to serialized JSON `data`, including quote/newline escaping and object keys.
360
+ Oversized input fails before commands run and reports its actual serialized size.
361
+ Split larger documents into
310
362
  separate invocations: first `write(path, firstChunk)`, then
311
363
  `write({path,content:nextChunk,append:true})`. Append uses the complete internal
312
364
  file buffer, never a bounded model-facing read; it retains conflict checks and
@@ -316,7 +368,9 @@ file and publish it only when complete. External write overrides reject append.
316
368
 
317
369
  Supernova is a bounded foreground executor, not a durable background-job manager.
318
370
  For long archive scans, use resumable chunks or a host background-job tool and write
319
- progress records under `.work`. Set the inner `bash` timeout shorter than the
371
+ progress records under `.work`. Shell commands inherit the current program
372
+ `timeoutMs` unless they specify their own; increasing the outer deadline no longer
373
+ leaves a hidden 60-second shell cap. Set the inner `bash` timeout shorter than the
320
374
  outer program timeout (for example 10 seconds inside a 20-second program) to retain
321
375
  bounded shell diagnostics. A hard guest deadline cannot guarantee pending shell
322
376
  output delivery; progress files survive shell execution but staged VFS writes may
@@ -344,12 +398,21 @@ Inputs are capped at 16 MiB, including staged files. JSON reads require regular
344
398
  files and reject named pipes without waiting for a writer. The entire input must
345
399
  be valid JSON before any selection. Each selector is budgeted before allocating
346
400
  the next slice; sparse selector/path/edit arrays are rejected. Selected JSON must
347
- fit the ordinary read budget or the
348
- read throws; it is never returned as malformed/truncated JSON. Oversized unwindowed
401
+ fit the ordinary read budget. Oversized selections return a routing object
402
+ `{status:"too_large",path,keys}` (or `length` for an array), not the requested
403
+ array/object: check `status` before calling `.map` or `.filter`, then select
404
+ narrower fields or slices. The input-size cap still throws before projection;
405
+ JSON is never returned malformed or silently truncated. Oversized unwindowed
349
406
  plain .json reads also fail with a projection hint. Explicit offset/limit or
350
407
  resolve:true still allow raw inspection, but line windows are not JSON documents.
351
408
  Do not combine json with complete, line windows, or source views. External read
352
409
  overrides reject JSON projection rather than silently ignoring the option.
410
+ Uncaught read errors abort the program, including `return {a:await read(...),
411
+ b:await read(...)}`; earlier successful values are not an implicit partial return.
412
+ For optional sources, explicitly return `await Promise.allSettled(paths.map(path =>
413
+ read(path)))`. This keeps successful text and per-path errors without weakening
414
+ rollback for uncaught failures.
415
+
353
416
  Other read options, even false-valued flags, do not bypass a captured external
354
417
  read executor; its policy, transforms and failures remain authoritative.
355
418
 
@@ -386,9 +449,11 @@ recovery backups before retrying. Import-based mutations and shell side effects
386
449
  are outside the VFS counters; this is not a filesystem audit.
387
450
 
388
451
  `edit(async () => {...})` creates a nested filesystem checkpoint. It returns
389
- `{ok:true,committed:true,value}` on success or `{ok:false,committed:false,error}` on
390
- failure. Shell commands, overlapping/nested checkpoints, and concurrent commands
391
- outside the active callback are rejected. Await the checkpoint before proceeding.
452
+ `{ok:true,committed:true,value}` on success. On failure it rolls back and rethrows
453
+ the cause, so an ignored failed checkpoint cannot report program success. Use
454
+ `try { await edit(async () => {...}); } catch (error) {...}` for deliberate recovery.
455
+ Shell commands, overlapping/nested checkpoints, and concurrent commands outside
456
+ the active callback are rejected. Await the checkpoint before proceeding.
392
457
 
393
458
  ## Context, caching and failure fidelity
394
459
 
@@ -396,9 +461,15 @@ outside the active callback are rejected. Await the checkpoint before proceeding
396
461
  is not proof the model still retains an earlier result after compaction.
397
462
  - Oversized text reads provide an exact next-line offset. A single line too large
398
463
  for the budget fails explicitly instead of pretending it was read completely.
464
+ - Model attachments support PNG, JPEG, GIF and WebP. BMP and other unsupported
465
+ MIME types fail before attachment or commit, rather than causing a provider
466
+ HTTP 400 on the next request. Convert those sources to PNG first; Supernova
467
+ does not silently convert, resize or modify the original image.
399
468
  - Returned images remain image content blocks, including in arrays/objects. Images
400
469
  not returned by the program stay out of model output. Returned images are limited
401
- to 16 attachments / 20 MiB; resize or return fewer when necessary.
470
+ to 16 attachments / 20 MiB. Overflow fails the program with the aggregate count
471
+ and byte size, returns no images, and rolls back pending writes. Resize or return
472
+ fewer images when necessary.
402
473
  - Dense multiline string arrays can render as verbatim source blocks instead of
403
474
  escaped string literals. Each block gives its array index and exact UTF-16 length;
404
475
  strings and result types are unchanged. This is output framing, not source
@@ -455,7 +526,10 @@ There is no dependency on or automatic routing to any consumer package.
455
526
 
456
527
  CodeMode executes trusted JavaScript in a terminable worker, **not a security
457
528
  sandbox**. The four adapters constrain writes/edits to the workspace and allow
458
- explicit external reads. JavaScript imports and shell commands still have process
529
+ explicit external reads. Absolute temporary-directory paths outside the workspace
530
+ are not write destinations: use a workspace path such as `.work/verification.log`,
531
+ or a separately authorized external command. Errors identify the rejected path
532
+ and workspace, including symlink escapes. JavaScript imports and shell commands still have process
459
533
  privileges. Do not run untrusted programs as though these adapters isolate them.
460
534
 
461
535
  Pi preflights the outer `supernova` call. Internal primitives do not emit ordinary
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,69 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.8.0] - 2026-09-19
6
+
7
+ ### Added
8
+
9
+ - Program batches accept a top-level `code` OR `file` as a source default, so a
10
+ shared program is sent once instead of in every entry. Entries may still
11
+ override it, defaults count once against the 48,000-character admission cap,
12
+ and every entry keeps its own fresh guest and its own commit.
13
+ - `mergeData: true` opts into a shallow object overlay of the top-level `data`
14
+ object and each entry's own object data (entry keys win; nested objects are
15
+ replaced, not merged). Whole-input replacement stays the default.
16
+
17
+ ### Changed
18
+
19
+ - Standing reference now states the read limits and their recovery forms
20
+ (path-only `160 lines / 8192 characters`, `offset`/`about`/`complete:true`,
21
+ one-based windows, the complete-read budget) and the optional-read contract
22
+ (`Promise.allSettled` keeps successful siblings): +35/+38 definition tokens per
23
+ request versus 0.7.1, paid back many times over on repeated-source batches.
24
+ - `bash()` inherits the program's `timeoutMs` instead of an independent 60s
25
+ default; explicit per-command limits still win.
26
+ - Missing-file errors point at directory/source-question recovery and
27
+ `Promise.allSettled`.
28
+ - Result packaging reuses decoded result branches and skips escaped rendering
29
+ when the complete raw framing is provably shorter; settled programs no longer
30
+ arm a 250 ms drain timer.
31
+
32
+ ### Fixed
33
+
34
+ - Failed `edit(async () => {...})` checkpoints roll back and rethrow the original
35
+ cause instead of reporting success; an explicit `catch` still recovers.
36
+ - Pi failure cards render as failures: the renderer reads the host's error flag
37
+ from render context, shows the original cause, prints `committed`/`rolledBack`
38
+ totals, marks writes whose persistence cannot be attributed as attempted, and
39
+ labels pure JavaScript execution instead of "complete".
40
+ - Unsupported image formats (for example BMP) fail before model delivery or
41
+ commit with PNG-conversion guidance instead of attaching unusable data.
42
+ - Oversized image sets report aggregate sizes instead of silently dropping
43
+ attachments; pending writes roll back.
44
+ - Rust lifetimes and loop labels no longer produce false edit/write warnings;
45
+ genuinely broken strings, brackets and character literals still warn.
46
+ - Markdown/MDX/RST/TXT edits skip declaration-reference searches for fenced code
47
+ and capital labels; usage evidence ignores declarations, generic calls and
48
+ prefix-only matches.
49
+ - Missing argv data identifies the offending argument index; oversized `data`
50
+ reports its serialized size and a lossless chunked-write recovery.
51
+
52
+ ### Internals
53
+
54
+ - 243 package tests, actual Pi and OMP host smoke, isolated Spark run, a
55
+ 536-program stress pass, and both frozen token gates. `docs/TOKEN_COSTS.md`
56
+ records the traffic baselines, their limits and the measured 0.7.1 comparisons.
57
+
58
+ ## [0.7.1] - 2026-09-17
59
+
60
+ ### Fixed
61
+
62
+ - Guest worker links on hosts without `module.registerHooks` (Bun, Node <22.15):
63
+ `node:module` is now a namespace import with runtime feature detection, so the
64
+ `register` fallback — and hosts with neither hook mechanism — no longer fail at
65
+ module-eval time. Previously every program failed before its first command with
66
+ "Export named 'registerHooks' not found", e.g. under OMP/Bun.
67
+
5
68
  ## [0.7.0] - 2026-09-17
6
69
 
7
70
  ### Internals
@@ -21,6 +21,154 @@ The benchmark uses js-tiktoken 1.0.21, pinned as a development dependency. It ru
21
21
  real Supernova programs in temporary workspaces without provider calls or downloads.
22
22
  The token regression gate also runs in the normal test suite.
23
23
 
24
+ ## Unreleased explicit batch reuse (2026-09-19)
25
+
26
+ **79.82% / 79.92% less traffic on the shared-source/object-input workload, not a
27
+ claim of 80% savings on every task.** Source and object defaults remove repeated
28
+ arguments without compression, citations, result elision, context rewriting,
29
+ reduced limits or changes to reasoning settings. Every program still executes in
30
+ a fresh guest with its own commit. No model decision point is removed.
31
+
32
+ The missing capability was combining common executable/input values with distinct
33
+ per-entry inputs without repeating them or first saving helper files. Batches now
34
+ accept top-level `code` OR `file` as a source default. `mergeData:true` explicitly
35
+ opts into a shallow object overlay; the legacy whole-input replacement remains
36
+ the default. Entries can override the source, and nested data objects are replaced,
37
+ not recursively merged. Nothing is inferred or automatically deduplicated.
38
+
39
+ ### Frozen eligible workload
40
+
41
+ The pre-feature working tree, including the earlier unreleased papercut fixes,
42
+ ran 16 independent package-doc scaffolds. Each program wrote a LICENSE and README,
43
+ reread both exact staged contents, and returned its complete receipts. The control
44
+ repeated the same executable and literal license/introduction with a distinct
45
+ directory per entry. Its encoded program array was **32,353 characters**, within
46
+ the existing 48,000-character admission cap. This is not an inadmissible baseline.
47
+
48
+ The candidate supplies the same complete code and common data once, plus those
49
+ same per-entry directories. Both arms use **one tool invocation plus the final
50
+ model handoff**, the same 16-program schedule, and all 32 final files. The baseline
51
+ was captured before the implementation; neither program source nor data was padded
52
+ or changed to meet the gate.
53
+
54
+ | Tokenizer | Before | After | Reduction | Argument tokens before / after | Unchanged result tokens |
55
+ | --- | ---: | ---: | ---: | ---: | ---: |
56
+ | o200k_base | 17,771 | 3,587 | **79.82%** | 7,685 / 643 | 1,039 |
57
+ | cl100k_base | 17,595 | 3,533 | **79.92%** | 7,619 / 637 | 1,007 |
58
+
59
+ Accounting is `2*definition + 2*arguments + complete result`. It includes both
60
+ requests' standing definitions, generated arguments and their replay. The complete
61
+ normalized output equals the pre-feature snapshot byte-for-byte, and the contents
62
+ and complete inventory of all 32 files are checked. Only run counters and elapsed
63
+ times are normalized, as in the existing benchmark. No hidden helper files appear.
64
+
65
+ The new gate requires **at least 78% on both tokenizers**, not an argument-only
66
+ percentage. Its fixture is
67
+ [`batch-reuse-baseline.json`](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/tests/efficiency/batch-reuse-baseline.json),
68
+ with SHA-256 `589960c417006630872c16fae9ae5be4f5cdbdddbf86dda9fd9377651352055a`.
69
+
70
+ This workload benefits from substantial repeated source and input. Unique-input,
71
+ large-result, or already-file-backed jobs need not see that gain. Saving a reusable
72
+ program/input file was already an alternative when extra workspace artifacts and
73
+ setup were acceptable; this is not a claim of 80% over that different workflow.
74
+
75
+ ### Existing workload stays separate
76
+
77
+ The original six-call schedule, its argument hash, all 13 logical operations,
78
+ failures, decision boundaries and complete outputs remain unchanged. It already
79
+ uses saved programs and does not exercise the new source defaults. Clearer shorter
80
+ standing guidance offsets the new option's schema cost:
81
+
82
+ | Tokenizer | At start of this pass | Now | Additional reduction | Versus original non-batched baseline |
83
+ | --- | ---: | ---: | ---: | ---: |
84
+ | o200k_base | 10,191 | 9,841 | **3.43%** | 65.02% |
85
+ | cl100k_base | 10,067 | 9,724 | **3.41%** | 65.07% |
86
+
87
+ The definition measures 631/626 tokens, versus 681/675 at the start. The original
88
+ traffic gate was strengthened from 40% to 65%, without changing its baseline. An
89
+ initial shorter guidance draft failed the existing recovery/surface checks; the
90
+ copyable offset recovery and explicit selector/view guidance were restored rather
91
+ than removing those requirements. There is no claim of 70--80% on this workload.
92
+
93
+ ### Verification and boundaries
94
+
95
+ New source/overlay regressions and the reuse gate failed before implementation.
96
+ The strengthened existing traffic gate also failed first. Current verification:
97
+ 239 package tests, both tokenizers' gates, lint, actual Pi argument validation and
98
+ TUI smoke, actual network-denied OMP execution, and 71 targeted tests on an isolated
99
+ Spark copy under Node v24.16.0. The actual-host probes exercise shared source,
100
+ object overlays and independent guest copies.
101
+
102
+ The existing stress runner passed 33,024 independent reads, 20 commits/140 conflicts,
103
+ cancellation, complete-source fidelity and program batches. On Apple M5 Max,
104
+ Node v26.7.0, the 300-sample, eight-file check measured pristine-warm median/p95
105
+ 1.645/2.390 ms versus unbatched-cold p95 13.848 ms; its existing acceptance check
106
+ passed. This is a runtime smoke measurement, **not a new end-to-end speedup claim**.
107
+
108
+ Complete-output and file equality prove the tested contracts, not unchanged
109
+ live-model quality. There was no provider/model A/B evaluation, no reasoning-budget
110
+ change, and no compression feature enabled. Deployment, session restart and
111
+ publication are separate; installed OMP packages were not changed by this pass.
112
+
113
+ ## Unreleased optimization pass (2026-09-19)
114
+
115
+ Measured against the working tree immediately before this pass, **including the
116
+ unreleased papercut fixes**, not against a published release or the older baselines
117
+ below. Machine: Apple M5 Max, 18 CPUs, 48 GiB RAM, macOS arm64, Node v26.7.0.
118
+
119
+ | Measurement | Before | After | Reduction |
120
+ | --- | ---: | ---: | ---: |
121
+ | Definition tokens, o200k_base | 699 | 658 | 5.87% |
122
+ | Definition tokens, cl100k_base | 691 | 651 | 5.79% |
123
+ | Fixed six-call traffic, o200k_base | 10,317 | 10,030 | 2.78% |
124
+ | Fixed six-call traffic, cl100k_base | 10,179 | 9,899 | 2.75% |
125
+ | 200-row report packaging, median | 0.1665 ms | 0.1314 ms | 21.09% |
126
+ | Nested source packaging, median | 0.0366 ms | 0.0240 ms | 34.53% |
127
+
128
+ A subsequent Cortex papercut follow-up adds explicit data-limit, workspace-write,
129
+ and inherited-timeout guidance. That later definition measures 674/667 tokens
130
+ (o200k_base/cl100k_base), with fixed-workload totals of 10,142/10,011. The table
131
+ above records the optimization checkpoint, before that additional guidance.
132
+
133
+ The Mac/Spark recheck adds optional-read recovery and removes the unsupported BMP
134
+ attachment advertisement. The resulting definition is 681/675 tokens, with fixed
135
+ traffic totals of 10,191/10,067. Both frozen-workload gates still pass. Verification
136
+ now includes 235 Mac package tests, 51 focused tests on an isolated Spark source
137
+ copy (Node v24.16.0), and actual Pi/OMP smoke on Mac. Installed OMP packages were
138
+ not updated by either follow-up.
139
+
140
+ Token savings come only from shorter standing guidance. Arguments, decision
141
+ boundaries and complete logical result text remain unchanged. No compression,
142
+ elision, lower output limits, or history rewriting was introduced or enabled.
143
+
144
+ Packaging now copies only changed branches and skips an escaped-source rendering
145
+ when the existing complete raw rendering is provably shorter. The benchmark runs
146
+ 10,000 measured iterations per payload after warmup. Complete packaged-output
147
+ SHA-256 hashes match before/after, including typed values, emitted text, images,
148
+ logs and truncation flags. Local acceptance: identical hashes and at least 10%
149
+ lower median packaging time for both fixtures; this is not a timing-sensitive CI
150
+ gate or an end-to-end agent speed claim.
151
+
152
+ Successful runs no longer leave a 250 ms drain timer alive; early completion of
153
+ pending calls clears its fallback timer. A cold child-process probe exited at
154
+ 267 ms before versus 17 ms after, while result delivery itself remained about
155
+ 17 ms in both. Cancellation and the 250 ms bound on stuck calls remain tested.
156
+ The 300-sample, eight-file benchmark measured prewarmed median/p95 of
157
+ 1.904/2.743 ms before and 1.765/2.530 ms after. These filesystem timings are noisy;
158
+ no generalized I/O speedup is claimed, and prewarming/model latency is excluded.
159
+
160
+ Reproduce the local latency and stress checks:
161
+
162
+ ~~~sh
163
+ SUPERNOVA_MEASURE_SAMPLES=300 npm run measure --prefix packages/pi-supernova
164
+ node packages/pi-supernova/tests/efficiency/stress.mjs
165
+ ~~~
166
+
167
+ Verification: 229 package tests, both tokenizers' existing frozen-workload gates,
168
+ lint, actual Pi loader/TUI and OMP execution smoke tests, plus the stress runner
169
+ (33,024 independent reads, write contention, cancellation and source fidelity).
170
+ No version bump, installation update, commit or publication is part of this pass.
171
+
24
172
  ## Workload
25
173
 
26
174
  The baseline is a frozen, non-batched implementation snapshot with program-file
@@ -72,12 +220,12 @@ text contributes to totals through later history, not as a second charge.
72
220
 
73
221
  Observed for 0.6.0 on 2026-09-15, on an Apple M5 Max running macOS and Node v26.7.0.
74
222
 
75
- | Tokenizer | Non-batched baseline | Batched baseline (d444eb7) | Current | Further reduction | Total reduction |
223
+ | Tokenizer | Non-batched baseline | Batched baseline (d444eb7) | 0.6.0 | Further reduction | Total reduction |
76
224
  | --- | ---: | ---: | ---: | ---: | ---: |
77
225
  | o200k_base | 28,130 | 18,535 | 9,843 | **46.90%** | **65.01%** |
78
226
  | cl100k_base | 27,841 | 18,310 | 9,726 | **46.88%** | **65.07%** |
79
227
 
80
- The gate requires at least 40% reduction on **each tokenizer for the complete
228
+ The gate now requires at least 65% reduction on **each tokenizer for the complete
81
229
  workload**, not for every scenario individually. Token counts and reductions are
82
230
  computed from the recorded baseline and fresh execution results, not constants
83
231
  returned by the runtime. A second gate requires another 19% against the measured
@@ -86,7 +234,7 @@ as the original workload: removing a decision boundary cannot satisfy this gate.
86
234
 
87
235
  ### Definition and result accounting
88
236
 
89
- The current serialized definition is 602 tokens with o200k_base and 595 with
237
+ The 0.6.0 serialized definition was 602 tokens with o200k_base and 595 with
90
238
  cl100k_base, versus 908 and 901 in the frozen non-batched baseline. It retains
91
239
  command signatures, complete-read and JSON limits, array-read failure rules,
92
240
  transaction boundaries, batch defaults and edit/view guidance on every request.
package/index.js CHANGED
@@ -212,6 +212,7 @@ export function registerCodeMode(pi) {
212
212
 
213
213
  function rejectLoneParallel(params) {
214
214
  if (params?.parallel !== undefined) throw new Error("parallel applies to the programs array; no commands ran");
215
+ if (params?.mergeData !== undefined) throw new Error("mergeData applies to the programs array; no commands ran");
215
216
  }
216
217
 
217
218
  function bindRunSignal(signal) {
@@ -224,8 +225,8 @@ export function registerCodeMode(pi) {
224
225
  return { runController, abortRun };
225
226
  }
226
227
 
227
- function openRunBridge(ctx, runCwd, budget, runController) {
228
- const runBridge = bridge.fork({ getCwd: () => runCwd, budget });
228
+ function openRunBridge(ctx, runCwd, budget, runController, timeoutMs) {
229
+ const runBridge = bridge.fork({ getCwd: () => runCwd, budget, timeoutMs });
229
230
  runBridge.bindCallContext(ctx, runController.signal);
230
231
  runBridge.resetCallBudget();
231
232
 
@@ -332,6 +333,7 @@ export function registerCodeMode(pi) {
332
333
  data: Type.Optional(Type.Unknown()),
333
334
  }, {additionalProperties:false}), {minItems:1,maxItems:32})),
334
335
  parallel: Type.Optional(Type.Boolean()),
336
+ mergeData: Type.Optional(Type.Boolean()),
335
337
  }),
336
338
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
337
339
  // so separate call/result slots cannot duplicate the lifecycle card.
@@ -345,7 +347,7 @@ export function registerCodeMode(pi) {
345
347
  cancelWarmTimer();
346
348
  const runCwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
347
349
  const { runController, abortRun } = bindRunSignal(signal);
348
- const runBridge = openRunBridge(ctx, runCwd, budget, runController);
350
+ const runBridge = openRunBridge(ctx, runCwd, budget, runController, params?.timeoutMs);
349
351
  const call = ++programSeq;
350
352
  runBridge.ledger.beginProgram(call);
351
353
  const emitProgress = progressEmitter(onUpdate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CodeMode for Pi and OMP: read, edit, write and bash, with transactional files, source views and shared-input program batches.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -43,7 +43,7 @@ export function createBash(ctx) {
43
43
  cwd: targetCwd,
44
44
  env: hooks.commandEnv(),
45
45
  commandLabel: literal ? command : undefined,
46
- timeoutMs: params?.timeoutMs,
46
+ timeoutMs: params?.timeoutMs === undefined ? config.timeoutMs : params.timeoutMs,
47
47
  signal,
48
48
  maxOutputChars: config.maxCallResultChars,
49
49
  });
@@ -131,6 +131,7 @@ export function createEdit(ctx) {
131
131
  }
132
132
 
133
133
  async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
134
+ if (/\.(md|mdx|rst|txt)$/i.test(target)) return "";
134
135
  const names = collectChangedNames(target, original, updated, diff);
135
136
 
136
137
  if (names.size === 0) return "";
@@ -143,7 +144,7 @@ export function createEdit(ctx) {
143
144
  } catch (error) {
144
145
  signal?.throwIfAborted();
145
146
 
146
- return "references unavailable: " + error.message;
147
+ return "references unavailable: " + String(error.message).slice(0, 512);
147
148
  }
148
149
  }
149
150
 
@@ -25,7 +25,7 @@ export function imageTooLarge(rel, size) {
25
25
  }
26
26
 
27
27
  export function missingFile(targetPath) {
28
- const error = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question)");
28
+ const error = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)");
29
29
  error.code = "ENOENT";
30
30
  return error;
31
31
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { isString, isNumber } from "../shared/decode.js";
3
+ import { isString, isNumber, assertModelImageMime } from "../shared/decode.js";
4
4
  import { extractStructuralSurface } from "../context/surface.js";
5
5
  import { pickSpan } from "../context/spans.js";
6
6
  import { executeSnap, tokenizeQuery, stem } from "../context/snap.js";
@@ -646,7 +646,8 @@ export function createRead(ctx) {
646
646
  const lines = contentLineInfo(loaded.text).count;
647
647
 
648
648
  if (n > RAW_SOURCE_CHARS || lines > RAW_SOURCE_LINES) {
649
- throw new Error("raw read of " + rel + " is " + lines + " lines; use about, offset/limit, or complete:true");
649
+ const p = JSON.stringify(rel);
650
+ throw new Error(`raw read of ${rel} is ${lines} lines / ${n} characters; path-only limit is ${RAW_SOURCE_LINES} lines / ${RAW_SOURCE_CHARS} characters. Use read(${p}, {offset:1, limit:80}) for a window, read(${p}, {about:"keywords"}) for matches, or read(${p}, {complete:true}) for the whole file within the read budget`);
650
651
  }
651
652
 
652
653
  return null;
@@ -656,6 +657,7 @@ export function createRead(ctx) {
656
657
  const mime = IMAGE_MIME[path.extname(targetPath).toLowerCase()];
657
658
 
658
659
  if (!mime) return null;
660
+ assertModelImageMime(mime);
659
661
  const bytes = await readImage(rel, targetPath, mime, signal);
660
662
 
661
663
  if (bytes.length > IMAGE_MAX_BYTES) throw imageTooLarge(rel, bytes.length);
@@ -684,7 +686,7 @@ export function createRead(ctx) {
684
686
 
685
687
  function assertComplete(rel, sliced, loaded, budget, params) {
686
688
  if (params.complete === true && (sliced !== loaded.text || sliced.length > budget || (params.resolve && !jsonFits(sliced, budget)))) {
687
- 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`);
689
+ throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget (${budget} characters). Use read(${JSON.stringify(rel)}, {offset:1, limit:80}) for a window, json:".field" for JSON reports, edit() for replacements, or bash({command,args}) with a bounded parser for large text/JSONL files`);
688
690
  }
689
691
  }
690
692
 
@@ -426,7 +426,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
426
426
  },
427
427
  },
428
428
  fork(options) {
429
- return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
429
+ const runConfig = options.timeoutMs === undefined ? config : { ...config, timeoutMs: Number(options.timeoutMs) };
430
+
431
+ return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
430
432
  },
431
433
  close() { closed = true; vfs.closed = true; },
432
434
  bindCallContext,
@@ -36,7 +36,7 @@ const EVIDENCE_DEFAULTS = {
36
36
  const IDENT = /[A-Za-z_$][\w$]*/g;
37
37
 
38
38
  // Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
39
- const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
39
+ const RELATION_WORDS = new Set(["calls", "called", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
40
40
 
41
41
  const HUB_FRACTION = 0.25;
42
42
 
@@ -575,6 +575,23 @@ function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
575
575
  return spans;
576
576
  }
577
577
 
578
+ // Usage queries naming an exact identifier must contain that identifier outside
579
+ // its declaration. Keep the matching line in the bounded window, even in long bodies.
580
+ function usageSpans(spans, profile, maxSpanLines) {
581
+ if (profile.answerType !== "usage" || !profile.subjects.length) return spans;
582
+ return spans.flatMap(span => {
583
+ for (let i = span.start - 1; i < span.sourceEnd; i++) {
584
+ const words = span.lines.idents[i];
585
+ const matched = profile.subjects.some(subject =>
586
+ words.filter(word => word === subject).length > Number(span.lines.defNames[i] === subject.toLowerCase()));
587
+ if (!matched) continue;
588
+ const start = Math.max(span.start, i - 1);
589
+ return [{ ...span, start, end: Math.min(span.sourceEnd, start + maxSpanLines - 1) }];
590
+ }
591
+ return [];
592
+ });
593
+ }
594
+
578
595
  function fuseScores(profile, graphNorm, hierNorm, rho) {
579
596
  const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
580
597
 
@@ -626,7 +643,7 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
626
643
 
627
644
  if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
628
645
  const { files: chosenFiles, fileScores } = candidateFiles(await listedFiles(root, searchDir, pendingPaths, index), profile, index, opts.maxCandidateFiles, overlayText);
629
- const spans = collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines);
646
+ const spans = usageSpans(collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines), profile, opts.maxSpanLines);
630
647
 
631
648
  if (spans.length === 0) return { route: profile.route, spans: [] };
632
649
  const { picks, fused } = pickEvidence(spans, fileScores, profile, opts);
@@ -11,7 +11,12 @@ function normalizeArgv(args) {
11
11
  if (args.args === undefined) return;
12
12
  if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
13
13
 
14
- for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error(ARGV_ERROR);
14
+ for (let i = 0; i < args.args.length; i++) {
15
+ if (!isString(args.args[i])) {
16
+ const type = args.args[i] === undefined ? "undefined" : "not a string";
17
+ throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
18
+ }
19
+ }
15
20
  args.args = args.args.map(String);
16
21
 
17
22
  if (process.platform === "win32") {
package/src/fs/check.js CHANGED
@@ -134,9 +134,17 @@ function consumeSlash(text, i, prev) {
134
134
  }
135
135
 
136
136
  /** Try to consume a comment, string, template, or regex at i. Returns { end, prev } | { error, at } | null. */
137
- function consumeLiteral(text, i, stack, prev) {
137
+ function consumeLiteral(text, i, stack, prev, rust) {
138
138
  const c = text[i];
139
139
 
140
+ if (rust && c === "'") {
141
+ const lifetime = /^'[\p{ID_Start}_][\p{ID_Continue}]*/u.exec(text.slice(i));
142
+ const end = i + (lifetime?.[0].length ?? 0);
143
+
144
+ // A closing apostrophe makes this a character literal, not a lifetime/label.
145
+ if (lifetime && text[end] !== "'") return { end, prev: "value" };
146
+ }
147
+
140
148
  if (c === '"' || c === "'" || c === "`") return consumeQuoted(text, i, stack);
141
149
 
142
150
  if (c !== "/") return null;
@@ -163,13 +171,13 @@ function bracket(c, i, stack, stopDepth) {
163
171
  }
164
172
 
165
173
  /** Skips comments, strings, templates and regex literals; `prev` is the last code token, which decides regex-vs-division. */
166
- function scan(text, start, stack, stopDepth) {
174
+ function scan(text, start, stack, stopDepth, rust = false) {
167
175
  let i = start;
168
176
  let prev = "";
169
177
 
170
178
  while (i < text.length) {
171
179
  const c = text[i];
172
- const literal = consumeLiteral(text, i, stack, prev);
180
+ const literal = consumeLiteral(text, i, stack, prev, rust);
173
181
 
174
182
  if (literal) {
175
183
  if (literal.error) return literal;
@@ -214,7 +222,7 @@ export function quickCheck(text, ext) {
214
222
 
215
223
  if (!CODE_EXT.has(ext)) return null;
216
224
  const stack = [];
217
- const r = scan(text, 0, stack);
225
+ const r = scan(text, 0, stack, undefined, ext === ".rs");
218
226
 
219
227
  if (r.error) return { ok: false, kind: "balance", message: r.error + " at line " + lineOf(text, r.at) };
220
228
 
package/src/fs/vfs.js CHANGED
@@ -91,7 +91,7 @@ function remapReadError(err, target) {
91
91
  if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
92
92
 
93
93
  if (err.code === "ENOENT") {
94
- const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
94
+ const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)');
95
95
  missing.code = "ENOENT";
96
96
  throw missing;
97
97
  }
@@ -100,7 +100,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
100
100
  const trimmed = assertFilesystemPath(inputPath, opName);
101
101
  const resolvedCwd = getResolvedCwd(cwd);
102
102
  const target = path.resolve(resolvedCwd, trimmed);
103
- assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
103
+ assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: ${JSON.stringify(trimmed)} resolves to ${target}, outside ${resolvedCwd}. Use a workspace-relative path (for example artifacts/output.log); external destinations require a separately authorized command`);
104
104
 
105
105
  if (!allowRoot && target === resolvedCwd) {
106
106
  throw new Error(`${opName} path cannot be the workspace root directory`);
@@ -122,7 +122,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
122
122
  realNearest.set(target, probe);
123
123
  }
124
124
 
125
- assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
125
+ assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink: ${JSON.stringify(trimmed)} resolves through ${probe}, outside ${realRoot}. Use a workspace-relative path without an external symlink`);
126
126
 
127
127
  return target;
128
128
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { isString, isObject } from "../shared/decode.js";
4
+ import { isString, isObject, mapChangedChildren, assertModelImageMime } from "../shared/decode.js";
5
5
  import { truncateChars, formatReturn, formatBoundedStringArray } from "./format.js";
6
6
 
7
7
  function json(value) {
@@ -239,15 +239,15 @@ export function packageHostResult(raw, config) {
239
239
 
240
240
  function collectImage(input, acc) {
241
241
  if (!(input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/"))) return null;
242
+ assertModelImageMime(input.mimeType);
242
243
  const size = Buffer.byteLength(input.data, "base64");
243
244
 
244
- if (acc.images.length >= 16 || acc.imageBytes + size > 20 * 1024 * 1024) {
245
+ acc.imageCount += 1;
246
+ acc.imageBytes += size;
247
+ if (acc.imageCount > 16 || acc.imageBytes > 20 * 1024 * 1024) {
245
248
  acc.imageOverflow = true;
246
-
247
- return "[image omitted: exceeds 16 attachments or 20 MiB]";
249
+ return "[image over budget]";
248
250
  }
249
-
250
- acc.imageBytes += size;
251
251
  acc.images.push({ type: "image", data: input.data, mimeType: input.mimeType });
252
252
 
253
253
  return `[image ${acc.images.length}: ${input.mimeType}]`;
@@ -258,11 +258,7 @@ function collectImages(input, acc) {
258
258
 
259
259
  if (replaced !== null) return replaced;
260
260
 
261
- if (Array.isArray(input)) return input.map(child => collectImages(child, acc));
262
-
263
- if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collectImages(child, acc)]));
264
-
265
- return input;
261
+ return mapChangedChildren(input, collectImages, acc);
266
262
  }
267
263
 
268
264
  function serializeReturn(value, formatted, maxReturn, imageOverflow) {
@@ -293,8 +289,11 @@ function clipLogs(logs, config) {
293
289
  }
294
290
 
295
291
  export function packageFinalReturn(value, logs, config) {
296
- const acc = { images: [], imageBytes: 0, imageOverflow: false };
292
+ const acc = { images: [], imageCount: 0, imageBytes: 0, imageOverflow: false };
297
293
  value = collectImages(value, acc);
294
+ if (acc.imageOverflow) {
295
+ throw new Error(`image attachment budget exceeded: ${acc.imageCount} images / ${acc.imageBytes} bytes; limit is 16 images / 20971520 bytes (20 MiB). No images returned; return fewer or smaller images per program`);
296
+ }
298
297
  const maxReturn = config.maxReturnChars ?? 32000;
299
298
  const serialized = serializeReturn(value, formatReturn(value), maxReturn, acc.imageOverflow);
300
299
  const clipped = clipLogs(logs, config);
@@ -1,4 +1,4 @@
1
- import { isString, isObject } from "../shared/decode.js";
1
+ import { isString, isObject, mapChangedChildren } from "../shared/decode.js";
2
2
 
3
3
  function normalizeText(text) {
4
4
  return isString(text) ? text : String(text ?? "");
@@ -109,33 +109,41 @@ function formatRawStringArray(value) {
109
109
  return raw.length < escapedSize ? raw : null;
110
110
  }
111
111
 
112
- function shouldFrameRaw(input) {
113
- return isString(input) && input.includes("\n") && !hasUnpairedSurrogate(input) && JSON.stringify(input).length - input.length > 64;
112
+ function rawStringSize(input) {
113
+ if (!isString(input) || !input.includes("\n") || hasUnpairedSurrogate(input)) return 0;
114
+ const size = JSON.stringify(input).length;
115
+
116
+ return size - input.length > 64 ? size : 0;
114
117
  }
115
118
 
116
- function visitRawStrings(input, strings) {
117
- if (shouldFrameRaw(input)) {
118
- const index = strings.push(input) - 1;
119
+ function visitRawStrings(input, acc) {
120
+ const size = rawStringSize(input);
121
+
122
+ if (size) {
123
+ const index = acc.strings.push(input) - 1;
124
+ acc.escapedChars += size;
119
125
 
120
126
  return { [RAW_TEXT]: "raw[" + index + "]" };
121
127
  }
122
128
 
123
- if (Array.isArray(input)) return input.map(child => visitRawStrings(child, strings));
124
-
125
- if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visitRawStrings(child, strings)]));
126
-
127
- return input;
129
+ return mapChangedChildren(input, visitRawStrings, acc);
128
130
  }
129
131
 
130
- function framedReturn(value, escaped) {
131
- const strings = [];
132
- const referencedValue = visitRawStrings(value, strings);
132
+ function framedReturn(value) {
133
+ const acc = { strings: [], escapedChars: 0 };
134
+ const referencedValue = visitRawStrings(value, acc);
135
+ const { strings } = acc;
133
136
 
134
- if (!strings.length) return escaped;
137
+ if (!strings.length) return formatValue(value);
135
138
  // Keep every key, value, duplicate string and byte. References are unquoted
136
139
  // expressions, so literal "raw[0]" values and header-like source cannot collide.
137
140
  const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
138
141
 
142
+ // The ordinary rendering contains at least these complete escaped literals.
143
+ // If framing beats even that lower bound, do not build the discarded rendering.
144
+ if (framed.length < acc.escapedChars) return framed;
145
+ const escaped = formatValue(value);
146
+
139
147
  return framed.length < escaped.length ? framed : escaped;
140
148
  }
141
149
 
@@ -146,7 +154,7 @@ export function formatReturn(value) {
146
154
 
147
155
  if (rawArray !== null) return rawArray;
148
156
 
149
- return framedReturn(value, formatValue(value));
157
+ return framedReturn(value);
150
158
  }
151
159
 
152
160
  const RAW_TEXT = Symbol("raw text reference");
@@ -1,6 +1,6 @@
1
1
  import { parentPort } from "node:worker_threads";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { register, registerHooks } from "node:module";
3
+ import * as nodeModule from "node:module";
4
4
  import { isString, isObject, isFunction, toPlain, looksLikePath } from "../shared/decode.js";
5
5
  import { truncateChars } from "../output/format.js";
6
6
  import { gatherReadArgs, normalizeRead, decodeReadValue, assertReadPaths } from "../contract/read.js";
@@ -8,6 +8,8 @@ import { classifyEdit } from "../contract/edit.js";
8
8
  import { normalizeBash } from "../contract/bash.js";
9
9
  import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
10
10
 
11
+ const { register, registerHooks } = nodeModule;
12
+
11
13
  if (isFunction(registerHooks)) {
12
14
  registerHooks({
13
15
  resolve(specifier, context, nextResolve) {
@@ -20,8 +22,13 @@ if (isFunction(registerHooks)) {
20
22
  return nextResolve(specifier, context);
21
23
  },
22
24
  });
23
- } else {
24
- register("./guest-deny-imports.js", import.meta.url);
25
+ } else if (isFunction(register)) {
26
+ try {
27
+ register("./guest-deny-imports.js", import.meta.url);
28
+ } catch {
29
+ // Hosts whose module.register cannot run loader hooks lose the deny list;
30
+ // the guest remains trusted code, not a sandbox boundary.
31
+ }
25
32
  }
26
33
 
27
34
  // Guest programs run here, off the host thread. The host can terminate() this
@@ -235,7 +242,7 @@ async function runSpeculation(fn, token, checkpointScope, drainReads, enqueueHos
235
242
 
236
243
  if (began) await enqueueHost(() => rpc("speculateRollback", []));
237
244
 
238
- return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
245
+ throw err;
239
246
  }
240
247
  }
241
248
 
@@ -28,42 +28,56 @@ export function programBatchText(results, total, stopped = "", failed = 0) {
28
28
  }).join("");
29
29
  }
30
30
 
31
- function assertProgramEntry(p) {
31
+ function assertProgramEntry(p, defaults = {}) {
32
+ const source = p?.code === undefined && p?.file === undefined ? defaults : p;
33
+
32
34
  if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
33
- ((p.code === undefined) === (p.file === undefined)) || !isString(p.code ?? p.file) || !(p.code ?? p.file).trim()) {
34
- throw new Error("each program requires code OR file, with optional data; no nested batches or per-entry timeouts; no programs ran");
35
+ ((source.code === undefined) === (source.file === undefined)) || !isString(source.code ?? source.file) || !(source.code ?? source.file).trim()) {
36
+ throw new Error("each program requires code OR file (own or shared), with optional data; no nested batches or per-entry timeouts; no programs ran");
35
37
  }
36
38
  }
37
39
 
38
- function encodeBatchPayload(params) {
39
- const hasDefault = params.data !== undefined;
40
- let encoded;
40
+ const objectData = value => isObject(value) && !Array.isArray(value);
41
41
 
42
- try { encoded = JSON.stringify(hasDefault ? {programs:params.programs,data:params.data} : params.programs); } catch { throw new Error("programs and data must be JSON-serializable; no programs ran"); }
42
+ function applyBatchDefaults(parsed, mergeData) {
43
+ if (mergeData && !objectData(parsed.data)) throw new Error("mergeData requires top-level object data; no programs ran");
44
+ const source = parsed.code !== undefined ? {code:parsed.code} : parsed.file !== undefined ? {file:parsed.file} : {};
43
45
 
44
- return { hasDefault, encoded };
45
- }
46
+ return parsed.programs.map(program => {
47
+ assertProgramEntry(program, source);
48
+ const entry = program.code === undefined && program.file === undefined ? {...source,...program} : program;
46
49
 
47
- function applyDefaultData(parsed, hasDefault) {
48
- if (!hasDefault) return parsed;
49
- if (!Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
50
+ if (mergeData) {
51
+ if (program.data !== undefined && !objectData(program.data)) throw new Error("mergeData requires object data in every explicit entry; no programs ran");
52
+ // Shallow own-property overlay, including literal __proto__ keys. The
53
+ // runtime snapshots data again per guest; no mutable heap is shared.
54
+ entry.data = {...parsed.data,...program.data};
55
+ } else if (program.data === undefined && Object.hasOwn(parsed,"data")) entry.data = parsed.data;
50
56
 
51
- // The runtime snapshots data separately for each fresh guest. An explicit
52
- // entry replaces the default wholesale; falsy values are not missing values.
53
- return parsed.programs.map(program => program.data === undefined ? {...program,data:parsed.data} : program);
57
+ return entry;
58
+ });
54
59
  }
55
60
 
56
61
  function parseBatchPayload(params, config) {
57
- if (["code","file"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code or file; no programs ran");
58
-
59
62
  if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
63
+ if (params.mergeData !== undefined && params.mergeData !== true && params.mergeData !== false) throw new Error("mergeData must be boolean; no programs ran");
64
+ const defaults = Object.fromEntries(["code","file","data"].filter(key => params[key] !== undefined).map(key => [key,params[key]]));
65
+
66
+ if (defaults.code !== undefined || defaults.file !== undefined) assertProgramEntry(defaults);
67
+ for (const p of params.programs) assertProgramEntry(p, defaults);
68
+ const hasDefaults = Object.keys(defaults).length > 0;
69
+ let encoded;
70
+
71
+ try { encoded = JSON.stringify(hasDefaults ? {programs:params.programs,...defaults} : params.programs); } catch { throw new Error("programs and defaults must be JSON-serializable; no programs ran"); }
60
72
 
61
- for (const p of params.programs) assertProgramEntry(p);
62
- const { hasDefault, encoded } = encodeBatchPayload(params);
73
+ if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget (including shared code/file/data); no programs ran");
74
+ const parsed = hasDefaults ? JSON.parse(encoded) : {programs:JSON.parse(encoded)};
63
75
 
64
- if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
76
+ if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
65
77
 
66
- return applyDefaultData(JSON.parse(encoded), hasDefault);
78
+ // Validate and expand every entry before executing any. Defaults count once
79
+ // against admission, not once for each independent guest receiving a copy.
80
+ return applyBatchDefaults(parsed, params.mergeData === true);
67
81
  }
68
82
 
69
83
  function batchTimeoutMs(params, config) {
@@ -1,19 +1,15 @@
1
- // Standing tool description: sent on every request. One nova call, four commands inside.
2
- export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. file:path runs that program file instead. Put Markdown/scripts/argv in data. Guest has no fs/import/require.
3
-
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], .items.length, quoted keys, or true; 16 MiB cap; no jq
7
- raw JSON or selections over budget return {status:"too_large",path,keys} (length for arrays); narrow with json:".field" or slices
8
- read("symbol or question") → same view as resolve:true
9
- read({query,resolve:true}) → {status,path,line,text,...}
10
- read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
11
- write(path, text) → replace an unread file; write({path,content,append:true}) → append without a prior read. After read use edit; replace:true overrides.
12
- edit(path,oldText,newText) | edit({path,edits}) → numbered post-edit lines, checks, references
13
- edit(async () => {...}) → checkpoint: commit on success, rollback on throw
14
- bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
15
- bash({command,args}) → literal argv
16
-
17
- edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files. Array reads reject failures. Edits stage until success; bash commits preceding writes. Batch known reads, edits, and tests in this program (Promise.all, or programs with parallel:true).
18
- programs:[{code|file,data?},...] sequential fresh guests, separate commits; top-level data defaults per entry. Stop on failure keeps earlier commits. parallel:true runs disjoint entries concurrently. Separate supernova calls only when the next step needs a model decision.
1
+ // Standing tool description: sent on every request. No result or history compression.
2
+ export const REFERENCE = `JS body/async arrow with read/write/edit/bash; no fs/import/require. file runs workspace scripts. data holds literal text/scripts/argv (≤48000 serialized JSON chars).
3
+ read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 attachments/20 MiB total).
4
+ Path-only: ≤160 lines AND 8192 characters (UTF-16). Use read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded parser via bash.
5
+ read({path,json:selector}) → parsed JSON; selectors ".field", ".a[0:3]", ".a.length", quoted keys, true; 16 MiB input, no jq. Oversized → {status:"too_large",keys} (arrays: length); narrow selectors.
6
+ read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
7
+ read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
8
+ write(path,text) replaces unread workspace files; write({path,content,append:true}) appends without reading. After read: edit or replace:true.
9
+ edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) exact unique read text; numbered windows (including misses), checks/references.
10
+ edit(view,text) replaces the span; edit(view,old,new) matches uniquely within it. edit(async()=>{...}) checkpoint merges on success, rolls back/rethrows on failure; catch to recover.
11
+ bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv; bounded output, nonzero throws; inherits program timeout unless overridden.
12
+ Edits stage until success; bash commits first. Array errors abort; Promise.allSettled for optional reads.
13
+ programs:[{code?,file?,data?}] inherits top-level code OR file and data. Entry source overrides; data replaces unless mergeData:true (shallow objects, entry keys win).
14
+ Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known work; separate calls only for new decisions.
19
15
  `;
@@ -221,7 +221,7 @@ function admitData(data, cap) {
221
221
  const encoded = JSON.stringify(data);
222
222
 
223
223
  if (encoded === undefined) return { error: "data must be JSON-serializable" };
224
- if (encoded.length > cap) return { error: "data exceeds " + cap + " characters; split literal inputs across invocations" };
224
+ if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
225
225
 
226
226
  return { data: JSON.parse(encoded) };
227
227
  } catch { return { error: "data must be JSON-serializable" }; }
@@ -346,7 +346,12 @@ class GuestRun {
346
346
 
347
347
  async drainPending(outcome) {
348
348
  if (this.pending.size || !outcome.ok) this.cancelHost();
349
- await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => setTimeout(resolve, 250))]);
349
+ if (!this.pending.size) return;
350
+ let timer;
351
+
352
+ try {
353
+ await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => { timer = setTimeout(resolve, 250); })]);
354
+ } finally { clearTimeout(timer); }
350
355
  if (this.pending.size && outcome.ok) this.hostError ??= "program completed with a host call still running";
351
356
  }
352
357
 
@@ -19,6 +19,36 @@ export function looksLikePath(target) {
19
19
  );
20
20
  }
21
21
 
22
+ /** Transform children without copying unchanged result trees. Never mutate the input. */
23
+ export function mapChangedChildren(value, visit, context) {
24
+ const array = Array.isArray(value);
25
+
26
+ if (!array && !isObject(value)) return value;
27
+ let out = value;
28
+
29
+ for (const key of array ? value.keys() : Object.keys(value)) {
30
+ if (array && !(key in value)) continue;
31
+ const before = value[key];
32
+ const after = visit(before, context);
33
+
34
+ if (Object.is(before, after)) continue;
35
+ if (out === value) out = array ? value.slice() : { ...value };
36
+ // Define rather than assign: "__proto__" must remain an ordinary data key.
37
+ Object.defineProperty(out, key, { value: after, enumerable: true, writable: true, configurable: true });
38
+ }
39
+
40
+ return out;
41
+ }
42
+
43
+ const MODEL_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
44
+
45
+ /** Reject unsupported attachments before they can poison the next model request. */
46
+ export function assertModelImageMime(mimeType) {
47
+ if (!MODEL_IMAGE_MIMES.has(mimeType)) {
48
+ throw new Error("unsupported image attachment type " + mimeType + "; model images require PNG, JPEG, GIF, or WebP. Convert the image to PNG before reading/returning it; no image attached");
49
+ }
50
+ }
51
+
22
52
  const MAX_DEPTH = 64;
23
53
 
24
54
  const MAX_TYPED_ARRAY = 4096;
package/src/ui/render.js CHANGED
@@ -373,6 +373,8 @@ function formatTarget(op, budget) {
373
373
  function opMarker(theme, op, isPartial, isError) {
374
374
  if (op.ok === false) return theme.fg("error", "×");
375
375
 
376
+ if (op.mutationAttempt) return theme.fg("warning", "·");
377
+
376
378
  if (op.ok === true) return theme.fg("success", "✓");
377
379
 
378
380
  if (isPartial) return theme.fg("dim", "·");
@@ -430,8 +432,9 @@ function formatOpRow(theme, op, width, isPartial, isError) {
430
432
  const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
431
433
  const exit = appendExit(theme, op);
432
434
  const counts = appendDiffCounts(theme, op);
433
- const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text;
434
- const used = 2 + toolText.length + 1 + DURATION_COL + 2 + exit.width + counts.width;
435
+ const outcome = op.mutationAttempt ? "attempted " : "";
436
+ const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text + theme.fg("warning", outcome);
437
+ const used = 2 + toolText.length + 1 + DURATION_COL + 2 + exit.width + counts.width + outcome.length;
435
438
 
436
439
  return opRowSuffix(theme, op, prefix, Math.max(1, width - used));
437
440
  }
@@ -460,10 +463,22 @@ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, is
460
463
  if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
461
464
  }
462
465
 
463
- function appendError(lines, theme, payload, expanded, width) {
464
- const error = "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error");
466
+ function appendError(lines, theme, payload, _expanded, width) {
467
+ const errors = [payload?.error || "error"];
468
+ for (const [i, program] of (payload?.programs ?? []).entries()) {
469
+ if (program.details?.ok === false) errors.push(`program ${i + 1}: ${program.details.error || resultTextContent(program)}`);
470
+ }
471
+ for (const error of errors) for (const line of resultLines("✗ " + cleanBlockText(error), width)) lines.push(theme.fg("error", line));
472
+ }
465
473
 
466
- for (const line of expanded ? resultLines(error, width) : error.split("\n")) lines.push(theme.fg("error", line));
474
+ function appendMutations(lines, theme, payload, width) {
475
+ const m = payload?.mutations;
476
+ if (!m || !(m.committed || m.rolledBack || m.external || m.pendingCommits || m.recoveryFailed)) return;
477
+ const summary = `file versions: committed=${m.committed || 0} rolledBack=${m.rolledBack || 0}`
478
+ + (m.external ? `; external calls attempted=${m.external}` : "")
479
+ + (m.pendingCommits ? `; pendingCommits=${m.pendingCommits}` : "")
480
+ + (m.recoveryFailed ? "; recovery failed: inspect files" : "");
481
+ for (const line of resultLines(summary, width)) lines.push(theme.fg(m.rolledBack || m.recoveryFailed ? "warning" : "dim", line));
467
482
  }
468
483
 
469
484
  function appendResult(lines, theme, payload, expanded, width) {
@@ -502,17 +517,22 @@ function appendOverflow(lines, theme, trace, maxOps, isPartial) {
502
517
  }
503
518
 
504
519
  function appendEmptyOps(lines, theme, ops, isError, isPartial) {
505
- if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "no adapter calls"));
520
+ if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "JavaScript-only execution"));
506
521
  }
507
522
 
508
523
  function buildBodyLines(theme, width, { payload, context, expanded, isPartial, isError }) {
509
524
  const trace = traceFor(payload, context);
510
525
  const { maxOps, maxDiffLines } = bodyLimits(expanded, isPartial);
511
526
  const ops = operationsFromTrace(visibleTrace(trace, maxOps, isPartial));
527
+ // Trace success records an operation, not persistence of every staged version.
528
+ // Mixed rollback/commit counts cannot safely be attributed to individual rows.
529
+ for (const op of ops) op.mutationAttempt = op.ok === true && ["write", "edit", "patch"].includes(op.tool)
530
+ && (isPartial || isError || payload?.mutations?.rolledBack > 0 || payload?.mutations?.recoveryFailed);
512
531
  const lines = [];
513
532
  appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
514
533
  appendOverflow(lines, theme, trace, maxOps, isPartial);
515
- appendEmptyOps(lines, theme, ops, isError, isPartial);
534
+ if (!isPartial) appendMutations(lines, theme, payload, width);
535
+ if (Array.isArray(payload?.trace)) appendEmptyOps(lines, theme, ops, isError, isPartial);
516
536
  appendTail(lines, theme, payload, expanded, isError, width, ops.length === 0 && !isPartial);
517
537
 
518
538
  return { lines, opCount: trace.length };
@@ -594,12 +614,14 @@ function syncState(context, payload) {
594
614
  if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) context.state.wallMs = payload.wallMs;
595
615
  }
596
616
 
597
- function errorPayload(result) {
598
- return result?.isError ? { ok: false, error: result.content?.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") } : undefined;
617
+ function resultTextContent(result) {
618
+ return result?.content?.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") || "";
599
619
  }
600
620
 
601
- function payloadFromResult(result) {
602
- return result?.details ?? errorPayload(result);
621
+ function payloadFromResult(result, hostError) {
622
+ const payload = result?.details;
623
+ if (hostError) return {...payload, ok:false, error:payload?.error || resultTextContent(result) || "tool execution failed"};
624
+ return payload ?? {result:resultTextContent(result)};
603
625
  }
604
626
 
605
627
  function bindResultCard(host, options, context) {
@@ -619,9 +641,11 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
619
641
  themeArg,
620
642
  contextArg,
621
643
  );
622
- const payload = payloadFromResult(result);
644
+ // Pi omits isError from result and supplies it through render context.
645
+ const hostError = result?.isError === true || context?.isError === true || options?.isError === true;
646
+ const payload = payloadFromResult(result, hostError);
623
647
  syncState(context, payload);
624
- const isError = result?.isError || payload?.ok === false;
648
+ const isError = hostError || payload?.ok === false;
625
649
  const comp = bindResultCard(host, options, context);
626
650
  comp.set(theme, { payload, context, args, expanded, isPartial, isError, host });
627
651