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.
@@ -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
- const text = Buffer.concat(scan.parts, scan.collected).toString("utf8");
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
- return finishFileWindow(targetPath, stat, startLine, scan);
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 { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
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
- throw new Error("raw read of " + rel + " is " + lines + " lines; use about, offset/limit, or complete:true");
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; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
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
 
@@ -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
- let { previous: prevText, overlay, existingBytes } = snap;
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
- if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
66
- try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
67
- catch (error) { if (error.code !== "ENOENT") throw error; }
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
- return createHostBridge({ pi, config, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
429
+ const runConfig = options.timeoutMs === undefined ? config : { ...config, timeoutMs: Number(options.timeoutMs) };
430
+
431
+ return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
430
432
  },
431
433
  close() { closed = true; vfs.closed = true; },
432
434
  bindCallContext,
@@ -36,7 +36,7 @@ const EVIDENCE_DEFAULTS = {
36
36
  const IDENT = /[A-Za-z_$][\w$]*/g;
37
37
 
38
38
  // Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
39
- const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
39
+ const RELATION_WORDS = new Set(["calls", "called", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
40
40
 
41
41
  const HUB_FRACTION = 0.25;
42
42
 
@@ -575,6 +575,23 @@ function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
575
575
  return spans;
576
576
  }
577
577
 
578
+ // Usage queries naming an exact identifier must contain that identifier outside
579
+ // its declaration. Keep the matching line in the bounded window, even in long bodies.
580
+ function usageSpans(spans, profile, maxSpanLines) {
581
+ if (profile.answerType !== "usage" || !profile.subjects.length) return spans;
582
+ return spans.flatMap(span => {
583
+ for (let i = span.start - 1; i < span.sourceEnd; i++) {
584
+ const words = span.lines.idents[i];
585
+ const matched = profile.subjects.some(subject =>
586
+ words.filter(word => word === subject).length > Number(span.lines.defNames[i] === subject.toLowerCase()));
587
+ if (!matched) continue;
588
+ const start = Math.max(span.start, i - 1);
589
+ return [{ ...span, start, end: Math.min(span.sourceEnd, start + maxSpanLines - 1) }];
590
+ }
591
+ return [];
592
+ });
593
+ }
594
+
578
595
  function fuseScores(profile, graphNorm, hierNorm, rho) {
579
596
  const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
580
597
 
@@ -626,7 +643,7 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
626
643
 
627
644
  if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
628
645
  const { files: chosenFiles, fileScores } = candidateFiles(await listedFiles(root, searchDir, pendingPaths, index), profile, index, opts.maxCandidateFiles, overlayText);
629
- const spans = collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines);
646
+ const spans = usageSpans(collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines), profile, opts.maxSpanLines);
630
647
 
631
648
  if (spans.length === 0) return { route: profile.route, spans: [] };
632
649
  const { picks, fused } = pickEvidence(spans, fileScores, profile, opts);
@@ -11,7 +11,12 @@ function normalizeArgv(args) {
11
11
  if (args.args === undefined) return;
12
12
  if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
13
13
 
14
- for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error(ARGV_ERROR);
14
+ for (let i = 0; i < args.args.length; i++) {
15
+ if (!isString(args.args[i])) {
16
+ const type = args.args[i] === undefined ? "undefined" : "not a string";
17
+ throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
18
+ }
19
+ }
15
20
  args.args = args.args.map(String);
16
21
 
17
22
  if (process.platform === "win32") {
@@ -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;
@@ -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 (isObject(oldText) && !Array.isArray(oldText)) throw new Error(EDIT_USAGE);
43
+ if (Array.isArray(oldText)) return { path: p, edits: oldText };
44
44
 
45
- return Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
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
  }
@@ -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.parse(text);
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
 
@@ -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) => matchReplacement(target, content, 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
- try { return overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true }); }
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
- if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
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("read requires a regular file: " + target);
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("read path is a directory, not a file: " + target);
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
- await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
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
  }
@@ -100,7 +100,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
100
100
  const trimmed = assertFilesystemPath(inputPath, opName);
101
101
  const resolvedCwd = getResolvedCwd(cwd);
102
102
  const target = path.resolve(resolvedCwd, trimmed);
103
- assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
103
+ assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: ${JSON.stringify(trimmed)} resolves to ${target}, outside ${resolvedCwd}. Use a workspace-relative path (for example artifacts/output.log); external destinations require a separately authorized command`);
104
104
 
105
105
  if (!allowRoot && target === resolvedCwd) {
106
106
  throw new Error(`${opName} path cannot be the workspace root directory`);
@@ -122,7 +122,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
122
122
  realNearest.set(target, probe);
123
123
  }
124
124
 
125
- assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
125
+ assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink: ${JSON.stringify(trimmed)} resolves through ${probe}, outside ${realRoot}. Use a workspace-relative path without an external symlink`);
126
126
 
127
127
  return target;
128
128
  }
@@ -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 => failCommand(state, 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
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { isString, isObject } from "../shared/decode.js";
4
+ import { isString, isObject, mapChangedChildren, assertModelImageMime } from "../shared/decode.js";
5
5
  import { truncateChars, formatReturn, formatBoundedStringArray } from "./format.js";
6
6
 
7
7
  function json(value) {
@@ -239,15 +239,15 @@ export function packageHostResult(raw, config) {
239
239
 
240
240
  function collectImage(input, acc) {
241
241
  if (!(input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/"))) return null;
242
+ assertModelImageMime(input.mimeType);
242
243
  const size = Buffer.byteLength(input.data, "base64");
243
244
 
244
- if (acc.images.length >= 16 || acc.imageBytes + size > 20 * 1024 * 1024) {
245
+ acc.imageCount += 1;
246
+ acc.imageBytes += size;
247
+ if (acc.imageCount > 16 || acc.imageBytes > 20 * 1024 * 1024) {
245
248
  acc.imageOverflow = true;
246
-
247
- return "[image omitted: exceeds 16 attachments or 20 MiB]";
249
+ return "[image over budget]";
248
250
  }
249
-
250
- acc.imageBytes += size;
251
251
  acc.images.push({ type: "image", data: input.data, mimeType: input.mimeType });
252
252
 
253
253
  return `[image ${acc.images.length}: ${input.mimeType}]`;
@@ -258,11 +258,7 @@ function collectImages(input, acc) {
258
258
 
259
259
  if (replaced !== null) return replaced;
260
260
 
261
- if (Array.isArray(input)) return input.map(child => collectImages(child, acc));
262
-
263
- if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collectImages(child, acc)]));
264
-
265
- return input;
261
+ return mapChangedChildren(input, collectImages, acc);
266
262
  }
267
263
 
268
264
  function serializeReturn(value, formatted, maxReturn, imageOverflow) {
@@ -293,8 +289,11 @@ function clipLogs(logs, config) {
293
289
  }
294
290
 
295
291
  export function packageFinalReturn(value, logs, config) {
296
- const acc = { images: [], imageBytes: 0, imageOverflow: false };
292
+ const acc = { images: [], imageCount: 0, imageBytes: 0, imageOverflow: false };
297
293
  value = collectImages(value, acc);
294
+ if (acc.imageOverflow) {
295
+ throw new Error(`image attachment budget exceeded: ${acc.imageCount} images / ${acc.imageBytes} bytes; limit is 16 images / 20971520 bytes (20 MiB). No images returned; return fewer or smaller images per program`);
296
+ }
298
297
  const maxReturn = config.maxReturnChars ?? 32000;
299
298
  const serialized = serializeReturn(value, formatReturn(value), maxReturn, acc.imageOverflow);
300
299
  const clipped = clipLogs(logs, config);