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 +90 -16
- package/docs/CHANGELOG.md +63 -0
- package/docs/TOKEN_COSTS.md +151 -3
- package/index.js +5 -3
- package/package.json +1 -1
- package/src/adapters/bash.js +1 -1
- package/src/adapters/edit.js +2 -1
- package/src/adapters/errors.js +1 -1
- package/src/adapters/read.js +5 -3
- package/src/bridge/host-bridge.js +3 -1
- package/src/context/evidence.js +19 -2
- package/src/contract/bash.js +6 -1
- package/src/fs/check.js +12 -4
- package/src/fs/vfs.js +1 -1
- package/src/fs/workspace.js +2 -2
- package/src/output/bottleneck.js +11 -12
- package/src/output/format.js +24 -16
- package/src/runtime/guest-worker.js +11 -4
- package/src/runtime/program-batch.js +35 -21
- package/src/runtime/reference.js +14 -18
- package/src/runtime/runtime.js +7 -2
- package/src/shared/decode.js +30 -0
- package/src/ui/render.js +37 -13
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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`
|
|
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).
|
|
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`.
|
|
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
|
|
348
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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.
|
|
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
|
package/docs/TOKEN_COSTS.md
CHANGED
|
@@ -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) |
|
|
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
|
|
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
|
|
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
package/src/adapters/bash.js
CHANGED
|
@@ -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
|
});
|
package/src/adapters/edit.js
CHANGED
|
@@ -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
|
|
package/src/adapters/errors.js
CHANGED
|
@@ -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
|
}
|
package/src/adapters/read.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/context/evidence.js
CHANGED
|
@@ -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);
|
package/src/contract/bash.js
CHANGED
|
@@ -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++)
|
|
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
|
}
|
package/src/fs/workspace.js
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/output/bottleneck.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/src/output/format.js
CHANGED
|
@@ -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
|
|
113
|
-
|
|
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,
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
const
|
|
132
|
-
const referencedValue = visitRawStrings(value,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
((
|
|
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
|
-
|
|
39
|
-
const hasDefault = params.data !== undefined;
|
|
40
|
-
let encoded;
|
|
40
|
+
const objectData = value => isObject(value) && !Array.isArray(value);
|
|
41
41
|
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
62
|
-
const
|
|
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 (
|
|
76
|
+
if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
|
|
65
77
|
|
|
66
|
-
|
|
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) {
|
package/src/runtime/reference.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
// Standing tool description: sent on every request.
|
|
2
|
-
export const REFERENCE = `
|
|
3
|
-
|
|
4
|
-
read(path
|
|
5
|
-
read(
|
|
6
|
-
read({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
`;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
|
package/src/shared/decode.js
CHANGED
|
@@ -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
|
|
434
|
-
const
|
|
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,
|
|
464
|
-
const
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
598
|
-
return result?.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|