pi-supernova 0.7.1 → 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 +127 -44
- package/docs/CHANGELOG.md +87 -0
- package/docs/TOKEN_COSTS.md +151 -3
- package/index.js +5 -3
- package/package.json +1 -1
- package/src/adapters/bash.js +8 -1
- package/src/adapters/edit.js +2 -1
- package/src/adapters/errors.js +1 -1
- package/src/adapters/read.js +28 -7
- package/src/adapters/write.js +14 -5
- package/src/bridge/host-bridge.js +3 -1
- package/src/context/evidence.js +19 -2
- package/src/contract/bash.js +15 -1
- package/src/contract/edit.js +21 -3
- package/src/contract/read.js +15 -0
- package/src/fs/check.js +14 -5
- package/src/fs/text-ops.js +28 -3
- package/src/fs/vfs.js +21 -7
- package/src/fs/workspace.js +8 -3
- package/src/output/bottleneck.js +11 -12
- package/src/output/format.js +24 -16
- package/src/runtime/guest-worker.js +1 -1
- package/src/runtime/program-batch.js +43 -24
- package/src/runtime/program-file.js +4 -1
- package/src/runtime/reference.js +14 -18
- package/src/runtime/runtime.js +17 -4
- package/src/shared/decode.js +30 -0
- package/src/shared/syntax-context.js +31 -0
- package/src/shared/utf8.js +17 -0
- package/src/ui/render.js +37 -13
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";
|
|
@@ -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)];
|
|
@@ -646,7 +665,8 @@ export function createRead(ctx) {
|
|
|
646
665
|
const lines = contentLineInfo(loaded.text).count;
|
|
647
666
|
|
|
648
667
|
if (n > RAW_SOURCE_CHARS || lines > RAW_SOURCE_LINES) {
|
|
649
|
-
|
|
668
|
+
const p = JSON.stringify(rel);
|
|
669
|
+
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
670
|
}
|
|
651
671
|
|
|
652
672
|
return null;
|
|
@@ -656,6 +676,7 @@ export function createRead(ctx) {
|
|
|
656
676
|
const mime = IMAGE_MIME[path.extname(targetPath).toLowerCase()];
|
|
657
677
|
|
|
658
678
|
if (!mime) return null;
|
|
679
|
+
assertModelImageMime(mime);
|
|
659
680
|
const bytes = await readImage(rel, targetPath, mime, signal);
|
|
660
681
|
|
|
661
682
|
if (bytes.length > IMAGE_MAX_BYTES) throw imageTooLarge(rel, bytes.length);
|
|
@@ -684,7 +705,7 @@ export function createRead(ctx) {
|
|
|
684
705
|
|
|
685
706
|
function assertComplete(rel, sliced, loaded, budget, params) {
|
|
686
707
|
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
|
|
708
|
+
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
709
|
}
|
|
689
710
|
}
|
|
690
711
|
|
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 };
|
|
@@ -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") {
|
|
@@ -21,8 +26,17 @@ function normalizeArgv(args) {
|
|
|
21
26
|
} else args._directArgv = true;
|
|
22
27
|
}
|
|
23
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
|
+
}
|
|
24
37
|
export function normalizeBash(command, opts) {
|
|
25
38
|
const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
|
|
39
|
+
assertBashOptions(args);
|
|
26
40
|
normalizeArgv(args);
|
|
27
41
|
|
|
28
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
|
@@ -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;
|
|
@@ -204,7 +212,8 @@ export function quickCheck(text, ext) {
|
|
|
204
212
|
|
|
205
213
|
if (ext === ".json") {
|
|
206
214
|
try {
|
|
207
|
-
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);
|
|
208
217
|
|
|
209
218
|
return { ok: true, kind: "json" };
|
|
210
219
|
} catch (err) {
|
|
@@ -214,7 +223,7 @@ export function quickCheck(text, ext) {
|
|
|
214
223
|
|
|
215
224
|
if (!CODE_EXT.has(ext)) return null;
|
|
216
225
|
const stack = [];
|
|
217
|
-
const r = scan(text, 0, stack);
|
|
226
|
+
const r = scan(text, 0, stack, undefined, ext === ".rs");
|
|
218
227
|
|
|
219
228
|
if (r.error) return { ok: false, kind: "balance", message: r.error + " at line " + lineOf(text, r.at) };
|
|
220
229
|
|
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,10 +90,13 @@ 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
|
-
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
|
|
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)');
|
|
95
100
|
missing.code = "ENOENT";
|
|
96
101
|
throw missing;
|
|
97
102
|
}
|
|
@@ -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
|
@@ -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
|
}
|
|
@@ -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
|
|
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);
|