pi-supernova 0.8.0 → 0.8.1
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 +47 -38
- package/docs/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/src/adapters/bash.js +7 -0
- package/src/adapters/read.js +23 -4
- package/src/adapters/write.js +14 -5
- package/src/contract/bash.js +9 -0
- package/src/contract/edit.js +21 -3
- package/src/contract/read.js +15 -0
- package/src/fs/check.js +2 -1
- package/src/fs/text-ops.js +28 -3
- package/src/fs/vfs.js +20 -6
- package/src/fs/workspace.js +6 -1
- package/src/runtime/program-batch.js +8 -3
- package/src/runtime/program-file.js +4 -1
- package/src/runtime/runtime.js +10 -2
- package/src/shared/syntax-context.js +31 -0
- package/src/shared/utf8.js +17 -0
package/README.md
CHANGED
|
@@ -12,47 +12,56 @@ Ordinary JavaScript control flow remains available; the guest command bindings
|
|
|
12
12
|
are only `read`, `edit`, `write`, and `bash`. Supernova supplies retrieval,
|
|
13
13
|
transactional file operations, batching, bounded results and the grouped nova UI.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## What is new in 0.8.0
|
|
16
16
|
|
|
17
17
|
- **Shared program source:** top-level `code` or `file` supplies a batch default;
|
|
18
|
-
entries may override it.
|
|
18
|
+
entries may override it. A shared program is sent once instead of in every entry,
|
|
19
|
+
and defaults count once against the 48,000-character admission cap.
|
|
19
20
|
- **Explicit object defaults:** `mergeData:true` shallowly overlays per-entry data
|
|
20
|
-
onto common data
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- **
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
21
|
+
onto common data (entry keys win; nested objects are replaced). Whole-input
|
|
22
|
+
replacement remains the default.
|
|
23
|
+
- **Checkpoint failures throw:** a failed `edit(async () => {...})` rolls back and
|
|
24
|
+
rethrows its original cause; catch explicitly when rejecting a candidate is
|
|
25
|
+
intentional. Ignored failures no longer report success.
|
|
26
|
+
- **Accurate failure cards:** the nova card reads the host's error flag, shows the
|
|
27
|
+
original cause and `committed`/`rolledBack` totals, marks writes whose
|
|
28
|
+
persistence cannot be attributed as attempted, and labels pure JavaScript runs
|
|
29
|
+
instead of "complete".
|
|
30
|
+
- **Bounded, explicit reads:** errors state both limits (`160 lines / 8192
|
|
31
|
+
characters`) with copyable recovery (`offset`, `about`, `complete:true`, and
|
|
32
|
+
`Promise.allSettled` for optional siblings). Markdown edits skip code-reference
|
|
33
|
+
searches; exact-symbol evidence excludes generic matches.
|
|
34
|
+
- **Fail-closed images:** unsupported formats (for example BMP) fail before model
|
|
35
|
+
delivery with PNG-conversion guidance, and sets over 16 images / 20 MiB report
|
|
36
|
+
aggregate sizes instead of silently omitting attachments. Pending changes roll back.
|
|
37
|
+
- **Shell follows the program clock:** `bash()` inherits the program's `timeoutMs`;
|
|
38
|
+
explicit per-command limits still win.
|
|
39
|
+
|
|
40
|
+
### Tokens: 0.7.1 to 0.8.0 (`js-tiktoken`, `o200k_base` / `cl100k_base`)
|
|
41
|
+
|
|
42
|
+
| Metric | 0.7.1 | 0.8.0 | Change |
|
|
43
|
+
|---|---:|---:|---:|
|
|
44
|
+
| Standing definition per request | 596 / 588 | 631 / 626 | +35 / +38 |
|
|
45
|
+
| Frozen 6-call mixed workload, total traffic | 9,596 / 9,458 | 9,841 / 9,724 | +2.6% / +2.8% |
|
|
46
|
+
| 16-program job with shared source + data (32 files) | 17,771 / 17,595 | 3,587 / 3,533 | -79.8% / -79.9% |
|
|
47
|
+
| 8 programs sharing a 48-path input | 16,309 / 14,649 | 5,499 / 5,197 | -66.3% / -64.5% |
|
|
48
|
+
|
|
49
|
+
Rows 3-4 deliver identical complete outputs and files; only argument placement
|
|
50
|
+
changes. Row 2 repeats no inputs, so it pays the +35-token guidance and nothing
|
|
51
|
+
else. Method, gates and limits: [TOKEN_COSTS.md](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/TOKEN_COSTS.md).
|
|
52
|
+
|
|
53
|
+
### Speed: engine micro-benchmarks (Apple M5 Max, Node 26.7)
|
|
54
|
+
|
|
55
|
+
| Benchmark | Before | After | Change |
|
|
56
|
+
|---|---:|---:|---:|
|
|
57
|
+
| Package 200-row report (median, 10k iterations) | 0.1665 ms | 0.1314 ms | -21% |
|
|
58
|
+
| Package nested source object (median) | 0.0366 ms | 0.0240 ms | -35% |
|
|
59
|
+
| Idle worker exit | 267 ms | 17 ms | -94% |
|
|
60
|
+
| 8-file read wave p50 / p95 (300 samples) | 1.90 / 2.74 ms | 1.77 / 2.53 ms | -7% / -8% |
|
|
61
|
+
| Cold unbatched p95 vs coalesced warm p95 (8 reads) | 13.85 ms | 2.39 ms | -83% |
|
|
62
|
+
|
|
63
|
+
Identical output hashes before and after. Local engine benchmarks, not end-to-end
|
|
64
|
+
agent latency or provider time.
|
|
56
65
|
|
|
57
66
|
See the [changelog](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/CHANGELOG.md)
|
|
58
67
|
and [token measurements](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-supernova/docs/TOKEN_COSTS.md).
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.8.1] - 2026-09-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Reads, windows, edits and appends reject **non-UTF-8** files with the path and
|
|
10
|
+
a conversion hint instead of returning U+FFFD, which an edit or append could
|
|
11
|
+
have written back as corruption. Prefix windows still drop one partial
|
|
12
|
+
character at the byte cut; diff/receipt snapshots stay tolerant so an explicit
|
|
13
|
+
`replace:true` still overwrites any file.
|
|
14
|
+
- Unknown options now fail loudly on every command: `read` (for example a
|
|
15
|
+
foreign `{start,end}` window, which used to return the whole file) and
|
|
16
|
+
`bash`/`write`/`edit` (`env`, `maxOutputChars`, `mode`, `all` were dropped
|
|
17
|
+
silently). Each error names the option and the supported set.
|
|
18
|
+
- Filesystem failures name the path or command: write and edit no longer say
|
|
19
|
+
"read path is a directory"; `ENOTDIR`/`EACCES`/`EPERM`/`EROFS`/`ENOSPC` no
|
|
20
|
+
longer leak raw codes or the `.supernova-<uuid>.new` temporary; `bash cwd`
|
|
21
|
+
must be a directory (`spawn ENOTDIR` is gone).
|
|
22
|
+
- `readWindow` no longer leaks an unhandled rejection while `finally` awaits
|
|
23
|
+
`file.close()`; failing window reads settle cleanly.
|
|
24
|
+
- Edit target misses report the closest matching line with its exact bytes
|
|
25
|
+
instead of only the file head; multi-edit failures name the entry
|
|
26
|
+
(`edit 2 of 3`); `edit(path,{oldText,newText}|{edits}|{patch})` dispatches.
|
|
27
|
+
- Syntax errors quote the offending source line and column with a caret;
|
|
28
|
+
`file:` programs name the file, and invalid UTF-8 names the file too.
|
|
29
|
+
- JSON projection reports the parse position with the offending line when V8
|
|
30
|
+
provides one, and ignores one leading BOM (also in write checks).
|
|
31
|
+
- Timeout and batch-deadline messages report elapsed time against the limit, and
|
|
32
|
+
a deadline-killed program is no longer reported as a plain program failure.
|
|
33
|
+
|
|
34
|
+
### Internals
|
|
35
|
+
|
|
36
|
+
- 259 package tests, actual Pi + OMP host smoke, isolated Spark run, a
|
|
37
|
+
536-program stress pass, and both frozen token gates.
|
|
38
|
+
|
|
5
39
|
## [0.8.0] - 2026-09-19
|
|
6
40
|
|
|
7
41
|
### Added
|
package/package.json
CHANGED
package/src/adapters/bash.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
1
2
|
import { isString } from "../shared/decode.js";
|
|
2
3
|
import { unwrapIfFullyQuoted } from "../fs/text-ops.js";
|
|
3
4
|
import { sourceForReferences } from "../fs/source-window.js";
|
|
@@ -35,6 +36,12 @@ export function createBash(ctx) {
|
|
|
35
36
|
const { literal, command, argv } = parseBash(params);
|
|
36
37
|
const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
|
|
37
38
|
|
|
39
|
+
if (params?.cwd !== undefined) {
|
|
40
|
+
const st = await fs.stat(targetCwd).catch(() => null);
|
|
41
|
+
|
|
42
|
+
if (!st?.isDirectory()) throw new Error("bash cwd is not a directory: " + params.cwd);
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
const transactionBarrier = await vfs.prepareExternalMutation("bash");
|
|
39
46
|
let res;
|
|
40
47
|
|
package/src/adapters/read.js
CHANGED
|
@@ -8,6 +8,7 @@ import { selectEvidence } from "../context/evidence.js";
|
|
|
8
8
|
import { WorkspaceIndex } from "../context/repo-index.js";
|
|
9
9
|
import { outlineFile } from "../context/outline.js";
|
|
10
10
|
import { MAX_JSON_BYTES, jsonProjector } from "../fs/json-read.js";
|
|
11
|
+
import { decodeUtf8Strict, decodeUtf8Window } from "../shared/utf8.js";
|
|
11
12
|
import { normalizeRead, classifyRead, needsProbe, SESSION_URI, buildJsonRouting, buildSelectionRouting, routingText } from "../contract/read.js";
|
|
12
13
|
import { resolveWorkspacePath, runCommand, relativeSlash } from "../fs/workspace.js";
|
|
13
14
|
import {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "../fs/text-ops.js";
|
|
20
21
|
import { imageTooLarge, missingFile, IMAGE_MAX_BYTES, LARGE_FILE_BYTES, ABOUT_TOKEN_MAX, IMAGE_MIME, RAW_JSON_CHARS, RAW_SOURCE_CHARS, RAW_SOURCE_LINES, ROUTING_MAX_CHARS } from "./errors.js";
|
|
21
22
|
import { outlineOptions, recordOutlineOrigins, createReferenceFinder } from "./refs.js";
|
|
23
|
+
import { sourceContext, parsePosition } from "../shared/syntax-context.js";
|
|
22
24
|
|
|
23
25
|
export function createRead(ctx) {
|
|
24
26
|
const { getCwd, vfs, config, index, ledger, hooks, reads } = ctx;
|
|
@@ -230,12 +232,18 @@ export function createRead(ctx) {
|
|
|
230
232
|
try { return await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
|
|
231
233
|
catch (error) {
|
|
232
234
|
if (error.code === "ENOENT") throw missingFile(targetPath);
|
|
235
|
+
if (error.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + targetPath + " is a file, not a directory");
|
|
236
|
+
if (error.code === "EISDIR") throw new Error("path is a directory, not a file: " + targetPath);
|
|
237
|
+
if (error.code === "EACCES" || error.code === "EPERM") throw new Error("permission denied reading " + targetPath + ": check the file mode (for example bash chmod)");
|
|
233
238
|
throw error;
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
async function finishFileWindow(targetPath, stat, startLine, scan) {
|
|
238
|
-
|
|
243
|
+
// A truncated window can cut a multi-byte character; a window that reached
|
|
244
|
+
// EOF must decode strictly, so a binary file cannot masquerade as text.
|
|
245
|
+
const bytes = Buffer.concat(scan.parts, scan.collected);
|
|
246
|
+
const text = scan.startByte + scan.collected >= stat.size ? decodeUtf8Strict(bytes, targetPath) : decodeUtf8Window(bytes);
|
|
239
247
|
const satisfied = (scan.done && scan.startByte + scan.collected >= scan.doneByte) || scan.startByte + scan.collected >= stat.size;
|
|
240
248
|
const whole = startLine === 1 && scan.startByte === 0 && scan.startByte + scan.collected >= stat.size;
|
|
241
249
|
await vfs.recordExpected(targetPath, stat);
|
|
@@ -262,7 +270,11 @@ export function createRead(ctx) {
|
|
|
262
270
|
|
|
263
271
|
if (!scan.started) return emptyWindow(targetPath, stat);
|
|
264
272
|
|
|
265
|
-
|
|
273
|
+
// Await inside the try: returning the promise directly leaves its
|
|
274
|
+
// rejection unobserved while the finally awaits file.close().
|
|
275
|
+
const window = await finishFileWindow(targetPath, stat, startLine, scan);
|
|
276
|
+
|
|
277
|
+
return window;
|
|
266
278
|
} finally {
|
|
267
279
|
await file.close();
|
|
268
280
|
}
|
|
@@ -501,13 +513,20 @@ export function createRead(ctx) {
|
|
|
501
513
|
return parts;
|
|
502
514
|
}
|
|
503
515
|
|
|
516
|
+
/** RFC 8259 lets parsers ignore one leading BOM; files from Windows tools carry it. */
|
|
517
|
+
const stripBom = text => text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
518
|
+
|
|
504
519
|
async function projectJson(rel, targetPath, params) {
|
|
505
520
|
const project = jsonProjector(params.json);
|
|
506
521
|
const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" });
|
|
507
522
|
let document;
|
|
508
523
|
|
|
509
|
-
try { document = JSON.parse(text); }
|
|
510
|
-
catch
|
|
524
|
+
try { document = JSON.parse(stripBom(text)); }
|
|
525
|
+
catch (error) {
|
|
526
|
+
const position = parsePosition(error.message, text);
|
|
527
|
+
|
|
528
|
+
throw new Error("invalid JSON in " + rel + ": " + error.message + "; the entire document must parse before projection" + (position ? sourceContext(text, position.line, position.column) : ""));
|
|
529
|
+
}
|
|
511
530
|
|
|
512
531
|
const many = Array.isArray(params.json);
|
|
513
532
|
const selectors = many ? params.json.map(String) : [params.json === true ? "." : String(params.json)];
|
package/src/adapters/write.js
CHANGED
|
@@ -44,7 +44,12 @@ function writeOutcome(rel, target, content, speculative, prevText, removedLines)
|
|
|
44
44
|
export function createWrite(ctx) {
|
|
45
45
|
const { getCwd, vfs, index } = ctx;
|
|
46
46
|
|
|
47
|
+
const WRITE_OPTION_KEYS = ["path", "content", "append", "replace", "allowReadArtifacts"];
|
|
48
|
+
|
|
47
49
|
function assertWriteParams(params) {
|
|
50
|
+
const unknown = Object.keys(params ?? {}).filter(key => !WRITE_OPTION_KEYS.includes(key));
|
|
51
|
+
|
|
52
|
+
if (unknown.length) throw new Error("write does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are " + WRITE_OPTION_KEYS.join(", "));
|
|
48
53
|
if (!isString(params?.content)) throw new Error("write requires string content");
|
|
49
54
|
assertWriteAppendFlag(params.append);
|
|
50
55
|
assertWriteArtifactsFlag(params.allowReadArtifacts);
|
|
@@ -58,13 +63,17 @@ export function createWrite(ctx) {
|
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
async function applyAppend(target, content, snap) {
|
|
61
|
-
|
|
66
|
+
const { overlay, existingBytes } = snap;
|
|
62
67
|
|
|
63
68
|
if (existingBytes > WRITE_APPEND_MAX_READ_BYTES) throw new Error("append input exceeds " + WRITE_APPEND_MAX_READ_BYTES + " bytes; stream it with bash redirection instead");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
// Append needs the real content: the diff snapshot may be a lossy decode, and
|
|
70
|
+
// concatenating that would silently corrupt a non-UTF-8 file.
|
|
71
|
+
let prevText;
|
|
72
|
+
|
|
73
|
+
try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code !== "ENOENT") throw error;
|
|
76
|
+
prevText = "";
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
return { content: prevText + content, prevText, removedLines: undefined };
|
package/src/contract/bash.js
CHANGED
|
@@ -26,8 +26,17 @@ function normalizeArgv(args) {
|
|
|
26
26
|
} else args._directArgv = true;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv"];
|
|
30
|
+
|
|
31
|
+
/** Unknown options used to be dropped silently: env/maxOutputChars never applied. */
|
|
32
|
+
function assertBashOptions(args) {
|
|
33
|
+
const unknown = Object.keys(args).filter(key => !BASH_OPTION_KEYS.includes(key));
|
|
34
|
+
|
|
35
|
+
if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs");
|
|
36
|
+
}
|
|
29
37
|
export function normalizeBash(command, opts) {
|
|
30
38
|
const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
|
|
39
|
+
assertBashOptions(args);
|
|
31
40
|
normalizeArgv(args);
|
|
32
41
|
|
|
33
42
|
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
package/src/contract/edit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isString, isObject, isFunction, isNumber } from "../shared/decode.js";
|
|
2
2
|
|
|
3
|
-
export const EDIT_USAGE = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
|
|
3
|
+
export const EDIT_USAGE = 'invalid edit signature; use edit(path,oldText,newText), edit(path,{oldText,newText}), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
|
|
4
4
|
|
|
5
5
|
function spanStart(value) {
|
|
6
6
|
return isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
|
|
@@ -40,9 +40,17 @@ function namedEditObject(p, oldText, newText) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function namedEditPositional(p, oldText, newText) {
|
|
43
|
-
if (
|
|
43
|
+
if (Array.isArray(oldText)) return { path: p, edits: oldText };
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
if (isObject(oldText)) {
|
|
46
|
+
// edit(path,{oldText,newText}|{edits}|{patch}): the path rides in either
|
|
47
|
+
// argument, but a conflicting path or third argument is still rejected.
|
|
48
|
+
if (newText !== undefined || (oldText.path !== undefined && oldText.path !== p)) throw new Error(EDIT_USAGE);
|
|
49
|
+
|
|
50
|
+
return { ...oldText, path: p };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { path: p, oldText, newText };
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
function normalizeEditArgs(p, oldText, newText) {
|
|
@@ -79,9 +87,19 @@ function classifyReplacements(args) {
|
|
|
79
87
|
return { kind: "edits", command: "edit", args };
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
const EDIT_OPTION_KEYS = ["path", "oldText", "newText", "edits", "patch"];
|
|
91
|
+
|
|
92
|
+
/** A view object carries host fields; only named replacements are validated. */
|
|
93
|
+
function assertEditOptions(args) {
|
|
94
|
+
const unknown = Object.keys(args).filter(key => !EDIT_OPTION_KEYS.includes(key));
|
|
95
|
+
|
|
96
|
+
if (unknown.length) throw new Error("edit does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are path, oldText, newText, edits, patch");
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
function classifyNamedEdit(p, oldText, newText) {
|
|
83
100
|
const args = namedEditArgs(p, oldText, newText);
|
|
84
101
|
assertNamedEditMode(args, oldText, newText);
|
|
102
|
+
assertEditOptions(args);
|
|
85
103
|
|
|
86
104
|
return args.patch !== undefined ? classifyPatch(args) : classifyReplacements(args);
|
|
87
105
|
}
|
package/src/contract/read.js
CHANGED
|
@@ -5,6 +5,20 @@ export const SESSION_URI = /^(?:agent|artifact):\/\//i;
|
|
|
5
5
|
|
|
6
6
|
const BOOL_KEYS = ["resolve", "complete", "outline", "evidence"];
|
|
7
7
|
|
|
8
|
+
const READ_OPTION_KEYS = ["path", "target", "about", "query", "offset", "limit", "json", "resolve", "complete", "outline", "evidence", "maxChars", "_independent"];
|
|
9
|
+
|
|
10
|
+
/** Unknown options used to be dropped silently: {start,end} read the whole file. */
|
|
11
|
+
function assertReadOptions(args) {
|
|
12
|
+
const unknown = Object.keys(args).filter(key => !READ_OPTION_KEYS.includes(key));
|
|
13
|
+
|
|
14
|
+
if (unknown.length === 0) return;
|
|
15
|
+
const windowHint = unknown.some(key => key === "start" || key === "end")
|
|
16
|
+
? " For a line window use read(path, {offset:1, limit:80}): offset is the first line and limit is the line count."
|
|
17
|
+
: "";
|
|
18
|
+
|
|
19
|
+
throw new Error("read does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are " + READ_OPTION_KEYS.filter(key => key !== "_independent").join(", ") + "." + windowHint);
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export function isSessionUri(value) {
|
|
9
23
|
return isString(value) && SESSION_URI.test(value);
|
|
10
24
|
}
|
|
@@ -62,6 +76,7 @@ export function normalizeRead(params) {
|
|
|
62
76
|
|
|
63
77
|
const args = sessionJsonArgs({ ...params, path: params.path ?? params.target });
|
|
64
78
|
validateJsonRead(args);
|
|
79
|
+
assertReadOptions(args);
|
|
65
80
|
assertReadFlags(args);
|
|
66
81
|
assertExclusiveRead(args);
|
|
67
82
|
assertReadPaths(args.path);
|
package/src/fs/check.js
CHANGED
|
@@ -212,7 +212,8 @@ export function quickCheck(text, ext) {
|
|
|
212
212
|
|
|
213
213
|
if (ext === ".json") {
|
|
214
214
|
try {
|
|
215
|
-
JSON.
|
|
215
|
+
// RFC 8259: a single leading BOM is ignorable; do not flag valid JSON.
|
|
216
|
+
JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
216
217
|
|
|
217
218
|
return { ok: true, kind: "json" };
|
|
218
219
|
} catch (err) {
|
package/src/fs/text-ops.js
CHANGED
|
@@ -235,6 +235,22 @@ function duplicateEditError(target, content, index, second) {
|
|
|
235
235
|
return new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lineAt(content, a)) + "\n" + formatNumberedLine(b, lineAt(content, b)));
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/** Exact bytes at the closest guess: a byte-for-byte miss is usually indentation drift. */
|
|
239
|
+
function nearMissPreview(content, oldText) {
|
|
240
|
+
const first = String(oldText).split("\n").find(line => line.trim().length > 0);
|
|
241
|
+
|
|
242
|
+
if (!first) return null;
|
|
243
|
+
const at = content.indexOf(first.trim());
|
|
244
|
+
|
|
245
|
+
if (at < 0) return null;
|
|
246
|
+
const line = lineNumberAt(content, at);
|
|
247
|
+
const start = Math.max(1, line - 2);
|
|
248
|
+
const from = lineStartIndex(content, start);
|
|
249
|
+
const shown = content.slice(from, lineEndIndex(content, from, Math.min(5, line - start + 3))).replace(/\r?\n$/, "");
|
|
250
|
+
|
|
251
|
+
return "first oldText line matches line " + line + " only after trimming; exact bytes there:\n"
|
|
252
|
+
+ shown.split("\n").map((text, index) => formatNumberedLine(start + index, text)).join("\n");
|
|
253
|
+
}
|
|
238
254
|
function matchReplacement(target, content, replacement) {
|
|
239
255
|
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
240
256
|
throw new Error("edit requires non-empty oldText");
|
|
@@ -246,7 +262,7 @@ function matchReplacement(target, content, replacement) {
|
|
|
246
262
|
const index = content.indexOf(oldText);
|
|
247
263
|
|
|
248
264
|
if (index < 0) {
|
|
249
|
-
throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
|
|
265
|
+
throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + (nearMissPreview(content, oldText) ?? numberedPreview(content)));
|
|
250
266
|
}
|
|
251
267
|
const second = content.indexOf(oldText, index + 1);
|
|
252
268
|
|
|
@@ -263,7 +279,13 @@ function assertNoOverlap(target, matches) {
|
|
|
263
279
|
|
|
264
280
|
export function applyReplacements(target, content, requestedEdits) {
|
|
265
281
|
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
266
|
-
const matches = requestedEdits.map((replacement) =>
|
|
282
|
+
const matches = requestedEdits.map((replacement, index) => {
|
|
283
|
+
try { return matchReplacement(target, content, replacement); }
|
|
284
|
+
catch (error) {
|
|
285
|
+
// Name the failing entry: a multi-edit miss is otherwise a guessing game.
|
|
286
|
+
throw requestedEdits.length === 1 ? error : new Error("edit " + (index + 1) + " of " + requestedEdits.length + ": " + error.message);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
267
289
|
matches.sort((a, b) => a.index - b.index);
|
|
268
290
|
assertNoOverlap(target, matches);
|
|
269
291
|
let updated = content;
|
|
@@ -380,7 +402,8 @@ async function snapshotLargeFile(vfs, target, overlay, signal) {
|
|
|
380
402
|
}
|
|
381
403
|
|
|
382
404
|
async function snapshotSmallFile(vfs, target, overlay) {
|
|
383
|
-
|
|
405
|
+
// Diff/receipt snapshot only: a lossy decode is acceptable and must not block a replace.
|
|
406
|
+
try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true, strict: false }); }
|
|
384
407
|
catch (error) {
|
|
385
408
|
if (error?.code !== "ENOENT") throw error;
|
|
386
409
|
|
|
@@ -391,6 +414,8 @@ async function snapshotSmallFile(vfs, target, overlay) {
|
|
|
391
414
|
async function existingStat(target) {
|
|
392
415
|
try { return await fs.stat(target); }
|
|
393
416
|
catch (error) {
|
|
417
|
+
if (error.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
|
|
418
|
+
|
|
394
419
|
if (error.code !== "ENOENT") throw error;
|
|
395
420
|
}
|
|
396
421
|
}
|
package/src/fs/vfs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isString } from "../shared/decode.js";
|
|
4
|
+
import { decodeUtf8Strict } from "../shared/utf8.js";
|
|
4
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
@@ -67,9 +68,10 @@ function overlayOrThrow(overlay, maxBytes, label) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
function assertReadableFile(stat, target) {
|
|
70
|
-
|
|
71
|
+
// Callers are reads, writes, edits and patch application: name the path, not the caller.
|
|
72
|
+
if (stat.isDirectory()) throw new Error("path is a directory, not a file: " + target);
|
|
71
73
|
|
|
72
|
-
if (!stat.isFile()) throw new Error("
|
|
74
|
+
if (!stat.isFile()) throw new Error("path is not a regular file: " + target);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
async function readLimitedBytes(file, stat, maxBytes, label, signal) {
|
|
@@ -88,7 +90,10 @@ async function readLimitedBytes(file, stat, maxBytes, label, signal) {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
function remapReadError(err, target) {
|
|
91
|
-
if (err.code === "EISDIR") throw new Error("
|
|
93
|
+
if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
|
|
94
|
+
|
|
95
|
+
if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
|
|
96
|
+
if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
|
|
92
97
|
|
|
93
98
|
if (err.code === "ENOENT") {
|
|
94
99
|
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)');
|
|
@@ -134,7 +139,16 @@ async function collectMissingAncestors(parent) {
|
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
async function writeTemporary(entry, content, stat) {
|
|
137
|
-
|
|
142
|
+
try {
|
|
143
|
+
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Never leak the temporary name: name the destination and the real cause.
|
|
146
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
|
|
147
|
+
if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
|
|
148
|
+
if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
|
|
149
|
+
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
138
152
|
|
|
139
153
|
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
140
154
|
}
|
|
@@ -258,7 +272,7 @@ export class CausalVfs {
|
|
|
258
272
|
return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
|
|
259
273
|
}
|
|
260
274
|
|
|
261
|
-
async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
|
|
275
|
+
async read(target, { preserveRead = false, maxBytes, label = "read input", strict = true } = {}) {
|
|
262
276
|
const overlay = this.getOverlay(target);
|
|
263
277
|
|
|
264
278
|
if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
|
|
@@ -281,7 +295,7 @@ export class CausalVfs {
|
|
|
281
295
|
// Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
|
|
282
296
|
if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
|
|
283
297
|
|
|
284
|
-
return bytes.toString("utf8");
|
|
298
|
+
return strict ? decodeUtf8Strict(bytes, target) : bytes.toString("utf8");
|
|
285
299
|
} catch (err) {
|
|
286
300
|
remapReadError(err, target);
|
|
287
301
|
}
|
package/src/fs/workspace.js
CHANGED
|
@@ -216,7 +216,12 @@ function attachCommandIO(state, options, argv, timeoutMs) {
|
|
|
216
216
|
child.stderr.setEncoding("utf8");
|
|
217
217
|
child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
|
|
218
218
|
child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
|
|
219
|
-
child.on("error", error =>
|
|
219
|
+
child.on("error", error => {
|
|
220
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") failCommand(state, new Error("cannot execute " + argv[0] + ": permission denied (is it executable?)"));
|
|
221
|
+
else if (error?.code === "ENOENT") failCommand(state, new Error("command not found: " + argv[0]));
|
|
222
|
+
else if (error?.code === "ENOTDIR") failCommand(state, new Error("cannot run " + argv[0] + ": the working directory is not a directory"));
|
|
223
|
+
else failCommand(state, error);
|
|
224
|
+
});
|
|
220
225
|
child.on("close", (code, signal) => onCommandClose(state, code, signal));
|
|
221
226
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
222
227
|
|
|
@@ -146,6 +146,10 @@ class ProgramBatch {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** Elapsed and limit: 'deadline or cancellation' alone cannot tell them apart. */
|
|
150
|
+
deadlineNote() {
|
|
151
|
+
return " (ran " + Math.round(performance.now() - this.started) + "ms of " + this.timeout + "ms)";
|
|
152
|
+
}
|
|
149
153
|
takeSettled(result, i) {
|
|
150
154
|
this.results.push(result);
|
|
151
155
|
this.trace.push(...(result.details?.trace ?? []));
|
|
@@ -170,7 +174,7 @@ class ProgramBatch {
|
|
|
170
174
|
this.takeSettled(settled[i], i);
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain";
|
|
177
|
+
if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
sequentialStop(result, i) {
|
|
@@ -185,14 +189,15 @@ class ProgramBatch {
|
|
|
185
189
|
|
|
186
190
|
if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
|
|
187
191
|
|
|
188
|
-
|
|
192
|
+
// The deadline explains a killed program better than "program N failed".
|
|
193
|
+
if (this.combined.aborted || performance.now() >= this.deadline) stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
|
|
189
194
|
|
|
190
195
|
return stopped;
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
async runSequential() {
|
|
194
199
|
for (const [i, program] of this.programs.entries()) {
|
|
195
|
-
if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run"; break; }
|
|
200
|
+
if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run" + this.deadlineNote(); break; }
|
|
196
201
|
|
|
197
202
|
const result = await this.runOne(program, i);
|
|
198
203
|
this.live[i] = [];
|
|
@@ -34,7 +34,10 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
|
|
|
34
34
|
|
|
35
35
|
signal?.throwIfAborted();
|
|
36
36
|
// Do not silently replace invalid bytes in executable source. Preserve BOMs.
|
|
37
|
-
|
|
37
|
+
let code;
|
|
38
|
+
|
|
39
|
+
try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); }
|
|
40
|
+
catch { throw new Error("program file " + file + " is not valid UTF-8 (encoded data could not be decoded); save it as UTF-8 text"); }
|
|
38
41
|
|
|
39
42
|
if (code.length > chars) throw tooLarge();
|
|
40
43
|
|
package/src/runtime/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { packageFinalReturn } from "../output/bottleneck.js";
|
|
|
6
6
|
import { truncateChars } from "../output/format.js";
|
|
7
7
|
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
8
8
|
import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
|
|
9
|
+
import { errorContext } from "../shared/syntax-context.js";
|
|
9
10
|
|
|
10
11
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
11
12
|
|
|
@@ -327,7 +328,7 @@ class GuestRun {
|
|
|
327
328
|
try { this.onTimeout?.(); } catch {}
|
|
328
329
|
|
|
329
330
|
this.aborting = false;
|
|
330
|
-
this.finish(this.fail(ABORT_MESSAGE));
|
|
331
|
+
this.finish(this.fail(ABORT_MESSAGE + " (ran " + Math.round(this.wall()) + "ms of " + this.timeoutMs + "ms)"));
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
postResult(message) {
|
|
@@ -434,7 +435,14 @@ class GuestRun {
|
|
|
434
435
|
if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
|
|
435
436
|
|
|
436
437
|
try { this.prepared = prepareProgram(this.code); }
|
|
437
|
-
catch (error) {
|
|
438
|
+
catch (error) {
|
|
439
|
+
const guidance = this.file === undefined
|
|
440
|
+
? " Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args})."
|
|
441
|
+
: " Fix " + this.file + " and re-run.";
|
|
442
|
+
|
|
443
|
+
this.finish(this.fail("JavaScript syntax error" + (this.file === undefined ? "" : " in " + this.file) + ": " + error.message + "; no commands ran." + guidance + errorContext(this.code, error)));
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
438
446
|
|
|
439
447
|
return true;
|
|
440
448
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Bounded source context for parse and syntax diagnostics. */
|
|
2
|
+
|
|
3
|
+
export function sourceContext(source, line, column) {
|
|
4
|
+
if (!Number.isInteger(line) || line < 1) return "";
|
|
5
|
+
const text = String(source).split("\n")[line - 1];
|
|
6
|
+
|
|
7
|
+
if (text === undefined) return "";
|
|
8
|
+
const shown = text.length > 160 ? text.slice(0, 160) : text;
|
|
9
|
+
const caret = Number.isInteger(column) ? " ".repeat(Math.min(column, shown.length)) + "^" : "";
|
|
10
|
+
|
|
11
|
+
return "\n " + shown + (caret ? "\n " + caret : "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** acorn-style error that carries a loc, when it has one. */
|
|
15
|
+
export function errorContext(source, error) {
|
|
16
|
+
return sourceContext(source, error?.loc?.line, error?.loc?.column);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** V8 "(line 5 column 3)" or "at position 42" parse messages → a position. */
|
|
20
|
+
export function parsePosition(message, source) {
|
|
21
|
+
const located = /\(line (\d+) column (\d+)\)/.exec(String(message));
|
|
22
|
+
|
|
23
|
+
if (located) return { line: Number(located[1]), column: Number(located[2]) };
|
|
24
|
+
const offsetMatch = /at position (\d+)/.exec(String(message));
|
|
25
|
+
|
|
26
|
+
if (!offsetMatch) return null;
|
|
27
|
+
const before = String(source).slice(0, Number(offsetMatch[1]));
|
|
28
|
+
const lines = before.split("\n");
|
|
29
|
+
|
|
30
|
+
return { line: lines.length, column: lines.at(-1).length };
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Strict decode: binary or non-UTF-8 text must fail loudly, never as U+FFFD. */
|
|
2
|
+
export function decodeUtf8Strict(bytes, target) {
|
|
3
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
|
4
|
+
catch {
|
|
5
|
+
throw new Error("not valid UTF-8 (binary or non-UTF-8 text): " + target + "; inspect or convert it with bash (for example iconv -f latin1 -t utf8 or xxd)");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Prefix window: the byte cut may split a character, so drop one partial tail. */
|
|
10
|
+
export function decodeUtf8Window(bytes) {
|
|
11
|
+
for (let cut = 0; cut <= 3 && cut < bytes.length; cut++) {
|
|
12
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytes.length - cut)); }
|
|
13
|
+
catch { /* a partial character at the cut is expected */ }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return bytes.toString("utf8");
|
|
17
|
+
}
|