pi-supernova 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +376 -221
  2. package/docs/CHANGELOG.md +107 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +39 -19
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +25 -9
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +86 -17
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. package/src/ui/trace.js +8 -5
@@ -9,11 +9,15 @@ function collectImage(input, acc) {
9
9
 
10
10
  acc.imageCount += 1;
11
11
  acc.imageBytes += size;
12
+
12
13
  if (acc.imageCount > 16 || acc.imageBytes > 20 * 1024 * 1024) {
13
14
  acc.imageOverflow = true;
15
+
14
16
  return "[image over budget]";
15
17
  }
18
+
16
19
  const bytes = decodeImageData(input.data);
20
+
17
21
  if (input.mimeType === "image/png") assertPng(bytes);
18
22
  acc.images.push({ type: "image", data: input.data, mimeType: input.mimeType });
19
23
 
@@ -23,12 +27,14 @@ function collectImage(input, acc) {
23
27
  function displayWeight(value,key) {
24
28
  // Count even omitted undefined fields: typed result metadata crosses RPC too.
25
29
  const field = isString(key) ? key.length+1 : 0;
30
+
26
31
  return field + (isString(value) ? value.length : 1) + (Array.isArray(value) ? value.length : 0);
27
32
  }
28
33
 
29
34
  function collectImages(input, acc, key) {
30
35
  const replaced = collectImage(input, acc);
31
36
  acc.displayUnits += displayWeight(replaced ?? input,key);
37
+
32
38
  if (replaced !== null) return replaced;
33
39
 
34
40
  return mapChangedChildren(input, collectImages, acc);
@@ -51,6 +57,7 @@ function clipLogLine(line, maxLogLineChars) {
51
57
  function clipLogs(logs, config) {
52
58
  const maxLines = config.maxLogLines ?? 100;
53
59
  let logTruncated = logs.length > maxLines;
60
+
54
61
  const clipped = logs.slice(0, maxLines).map(line => {
55
62
  const result = clipLogLine(line, config.maxLogLineChars ?? 4096);
56
63
  logTruncated ||= result.truncated;
@@ -67,19 +74,25 @@ function isSourceView(value) {
67
74
 
68
75
  function sourcePreview(value, maxReturn) {
69
76
  if (!isSourceView(value)) return null;
77
+
70
78
  if (value.text.length < maxReturn / 2) return null;
79
+
71
80
  if (value.text.length <= maxReturn && formatReturn(value).length <= maxReturn-256) return null;
72
81
  const base = {...value,text:"",complete:false,nextOffset:value.lines[0]+1};
73
82
  let room = maxReturn - formatReturn(base).length - 512;
83
+
74
84
  while (room > 0) {
75
85
  const end = value.text.lastIndexOf("\n",room-1) + 1;
86
+
76
87
  if (!end || end >= value.text.length) return null;
77
88
  const text = value.text.slice(0,end);
78
89
  const lines = [value.lines[0],value.lines[0]+text.match(/\n/g).length-1];
79
90
  const preview = {...base,text,lines,nextOffset:lines[1]+1};
91
+
80
92
  if (formatReturn(preview).length <= maxReturn-256) return preview;
81
93
  room = Math.floor(room / 2);
82
94
  }
95
+
83
96
  return null;
84
97
  }
85
98
 
@@ -88,23 +101,30 @@ function presentValue(value, maxReturn, oversized) {
88
101
  const bounded = truncateChars(value,maxReturn,"return");
89
102
  const result = serializeReturn(value,formatReturn(bounded.text),maxReturn,false);
90
103
  result.truncated ||= bounded.truncated;
104
+
91
105
  return result;
92
106
  }
107
+
93
108
  if (isStringArray(value) && oversized) {
94
109
  return {text:formatBoundedStringArray(value,maxReturn),truncated:true};
95
110
  }
111
+
96
112
  if (oversized) return {text:formatBoundedValue(value,maxReturn),truncated:true};
113
+
97
114
  return serializeReturn(value,formatReturn(value),maxReturn,false);
98
115
  }
99
116
 
100
117
  export function packageFinalReturn(value, logs, config) {
101
118
  const acc = { images: [], imageCount: 0, imageBytes: 0, imageOverflow: false, displayUnits:0 };
102
119
  value = collectImages(value, acc);
120
+
103
121
  if (acc.imageOverflow) {
104
122
  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`);
105
123
  }
124
+
106
125
  const maxReturn = config.maxReturnChars ?? 32000;
107
126
  const preview = sourcePreview(value,maxReturn);
127
+
108
128
  if (preview) value = preview;
109
129
  const serialized = presentValue(value,maxReturn,!preview && acc.displayUnits>maxReturn);
110
130
  const clipped = clipLogs(logs, config);
@@ -77,9 +77,19 @@ function hasUnpairedSurrogate(text) {
77
77
  return SURROGATE_CODE_UNIT.test(text) && UNPAIRED_SURROGATE.test(text);
78
78
  }
79
79
 
80
+ // Error messages bypass value formatting. Escape, rather than replace, invalid
81
+ // code units before budgeting so provider text remains well-formed and lossless.
82
+ export function escapeUnpairedSurrogates(text) {
83
+ return hasUnpairedSurrogate(text)
84
+ ? text.replace(/[\uD800-\uDFFF]/gu, unit => "\\u" + unit.charCodeAt(0).toString(16).padStart(4, "0"))
85
+ : text;
86
+ }
87
+
80
88
  export function isStringArray(value) {
81
89
  if (!Array.isArray(value) || !value.length) return false;
90
+
82
91
  for (const item of value) if (!isString(item)) return false;
92
+
83
93
  return true;
84
94
  }
85
95
 
@@ -91,6 +101,7 @@ function hasWellFormedStrings(values) {
91
101
 
92
102
  function boundedStringText(value, limit) {
93
103
  const text = truncateChars(value,limit,"return").text;
104
+
94
105
  return hasUnpairedSurrogate(text) ? truncateChars(JSON.stringify(text),limit,"return").text : text;
95
106
  }
96
107
 
@@ -100,15 +111,18 @@ export function formatBoundedStringArray(values, budget) {
100
111
  const header = "strings[" + n + "]\n";
101
112
  let remaining = Math.max(0, budget - header.length);
102
113
  let out = header;
114
+
103
115
  if (header.length >= budget) return truncateChars(header,budget,"return").text;
104
116
  const omitted = count => "…[return truncated; " + count + " string items omitted]…\n";
105
117
 
106
118
  for (let i = 0; i < n; i++) {
107
119
  const itemHeader = "[" + i + "] " + values[i].length + " UTF-16 units\n";
108
120
  const footer = i + 1 < n ? omitted(n - i - 1) : "";
121
+
109
122
  if (remaining < itemHeader.length + Math.min(32,values[i].length) + 1 + footer.length) {
110
123
  return out + truncateChars(omitted(n - i),remaining,"return").text;
111
124
  }
125
+
112
126
  const per = Math.min(remaining - itemHeader.length - 1 - footer.length, Math.max(32, Math.floor(remaining / (n - i)) - itemHeader.length - 1));
113
127
  const chunk = itemHeader + boundedStringText(values[i],per) + "\n";
114
128
  remaining = Math.max(0, remaining - chunk.length);
@@ -181,12 +195,17 @@ const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
181
195
  const FORMAT_WIDTH = 120;
182
196
 
183
197
  function formatKey(key) {
198
+ // Computed spelling retains an own data key instead of setting the prototype.
199
+ if (key === "__proto__") return '["__proto__"]';
200
+
184
201
  return IDENT_KEY.test(key) ? key : JSON.stringify(key);
185
202
  }
186
203
 
187
204
  function formatPrimitive(value) {
188
205
  if (value === undefined) return "undefined";
189
206
 
207
+ if (Object.is(value, -0)) return "-0";
208
+
190
209
  if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
191
210
 
192
211
  try { return JSON.stringify(value) ?? String(value); }
@@ -291,6 +310,7 @@ export function formatValue(value, indent = "", width = FORMAT_WIDTH, seen = new
291
310
  if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
292
311
 
293
312
  if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
313
+
294
314
  if (seen.has(value)) return "[Circular]";
295
315
  seen.add(value);
296
316
 
@@ -314,65 +334,106 @@ function displayKeys(value) {
314
334
  export function displayExceeds(value, budget) {
315
335
  const seen = new Set();
316
336
  const spend = units => (budget -= units) < 0;
337
+
317
338
  function container(item) {
318
339
  if (seen.has(item)) return false;
319
340
  seen.add(item);
320
341
  const array = Array.isArray(item);
342
+
321
343
  if (array && spend(item.length)) return true;
344
+
322
345
  for (const key of displayKeys(item)) {
323
346
  if (!array && spend(key.length+1)) return true;
347
+
324
348
  if (visit(item[key])) return true;
325
349
  }
350
+
326
351
  seen.delete(item);
352
+
327
353
  return false;
328
354
  }
355
+
329
356
  function visit(item) {
330
357
  if (spend(isString(item) ? item.length : 1)) return true;
358
+
331
359
  return isObject(item) || Array.isArray(item) ? container(item) : false;
332
360
  }
361
+
333
362
  return visit(value);
334
363
  }
335
364
 
336
- /** Display-only prefix; never constructs an oversized JSON/string rendering. */
365
+ function boundedQuotedText(text, budget, label) {
366
+ if (budget < 2) return "";
367
+ const end = maxJsonStringPrefix(text, budget);
368
+
369
+ if (end === text.length) return JSON.stringify(text);
370
+ const marker = "…[" + label + " truncated; " + text.length + " UTF-16 units]…";
371
+ const markerChars = JSON.stringify(marker).length - 2;
372
+
373
+ if (markerChars + 2 > budget) return JSON.stringify(text.slice(0, end));
374
+ const prefix = maxJsonStringPrefix(text, budget - markerChars);
375
+
376
+ return JSON.stringify(text.slice(0, prefix) + marker);
377
+ }
378
+
379
+ /** Fair display-only previews; bounded work, no expansion of full strings. */
337
380
  export function formatBoundedValue(value, budget, label = "return") {
338
- const footer = "\n…["+label+" truncated]…";
339
- const parts = [];
381
+ const footer = "\n…[" + label + " truncated]…";
382
+ const limit = charLimit(budget, 32000);
340
383
  const seen = new Set();
341
- let remaining = Math.max(0,budget-footer.length), stopped = false;
342
- const push = text => {
343
- if (text.length > remaining) { stopped = true; return false; }
344
- remaining -= text.length; parts.push(text); return true;
345
- };
346
- function quoted(text) {
347
- const end = maxJsonStringPrefix(text,remaining);
348
- push(JSON.stringify(text.slice(0,end)));
349
- if (end < text.length) stopped = true;
350
- }
351
- function visitChild(item,key,array) {
352
- if (!array) { quoted(key); push(":"); }
353
- visit(array && item[key] === undefined ? null : item[key]);
354
- }
355
- function container(item) {
356
- if (seen.has(item)) { push('"[Circular]"'); return; }
384
+
385
+ function render(item, room, depth) {
386
+ if (isString(item)) return boundedQuotedText(item, room, label);
387
+
388
+ if (!isObject(item) && !Array.isArray(item)) return truncateChars(formatPrimitive(item), room, label).text;
389
+
390
+ if (seen.has(item)) return truncateChars('"[Circular]"', room, label).text;
391
+
392
+ if (depth >= 32 || room < 4) return truncateChars("…[" + label + " truncated]…", room, label).text;
357
393
  seen.add(item);
358
- const array = Array.isArray(item);
359
- push(array ? "[" : "{");
360
- let first = true;
361
- for (const key of displayKeys(item)) {
362
- if (stopped) break;
363
- if (!first) push(",");
364
- first = false;
365
- visitChild(item,key,array);
394
+
395
+ try {
396
+ return container(item, room, depth);
397
+ } finally {
398
+ seen.delete(item);
366
399
  }
367
- if (!stopped) push(array ? "]" : "}");
368
- seen.delete(item);
369
400
  }
370
- function visit(item) {
371
- if (stopped) return;
372
- if (isString(item)) quoted(item);
373
- else if (isObject(item) || Array.isArray(item)) container(item);
374
- else push(formatPrimitive(item));
401
+
402
+ function container(item, room, depth) {
403
+ const array = Array.isArray(item);
404
+ const keys = displayKeys(item);
405
+ const count = array ? item.length : keys.length;
406
+ const parts = [array ? "[" : "{"];
407
+ let remaining = room - 2;
408
+ let index = 0;
409
+
410
+ for (const key of keys) {
411
+ const separator = index === 0 ? "" : ",";
412
+ const heading = array ? "" : boundedQuotedText(key, Math.max(2, Math.floor(remaining / 3)), "key") + ":";
413
+ const omitted = "…[" + label + " truncated; " + (count - index) + " items omitted]…";
414
+ const reserve = index + 1 < count ? omitted.length + 1 : 0;
415
+ const available = remaining - separator.length - heading.length - reserve;
416
+
417
+ if (available < 4) {
418
+ parts.push(truncateChars(omitted, remaining, label).text);
419
+ break;
420
+ }
421
+
422
+ // Small siblings give unused space back. Large siblings cannot consume
423
+ // later paths/results; very large collections disclose the omitted count.
424
+ const share = Math.min(available, Math.max(64, Math.floor(remaining / (count - index)) - heading.length - separator.length));
425
+ const child = array && item[key] === undefined ? null : item[key];
426
+ const text = separator + heading + render(child, share, depth + 1);
427
+
428
+ parts.push(text);
429
+ remaining -= text.length;
430
+ index += 1;
431
+ }
432
+
433
+ parts.push(array ? "]" : "}");
434
+
435
+ return parts.join("");
375
436
  }
376
- visit(value);
377
- return truncateChars(parts.join("")+footer,budget,"return").text;
437
+
438
+ return render(value, Math.max(0, limit - footer.length), 0) + truncateChars(footer, limit, label).text;
378
439
  }
@@ -1,5 +1,5 @@
1
1
  import {isString} from '../shared/decode.js';
2
- import {truncateChars,formatBoundedStringArray,isStringArray} from './format.js';
2
+ import {truncateChars,formatBoundedStringArray,isStringArray,escapeUnpairedSurrogates} from './format.js';
3
3
 
4
4
  function result(text, details) {
5
5
  return { content: [{ type: "text", text }], details };
@@ -11,14 +11,23 @@ function logsBlock(outcome, tail = "") {
11
11
  return `\n--- logs${outcome.logTruncated ? " [logs truncated]" : ""}\n${outcome.logs?.join("\n") ?? ""}${tail}`;
12
12
  }
13
13
 
14
- function mutationText(outcome) {
14
+ // Printed only when it informs: files committed or rolled back, an uncertain
15
+ // filesystem outcome, or (failures only) shell side effects that survive the
16
+ // rollback. A successful program rolls nothing back, so its shell calls need
17
+ // no warning.
18
+ function mutationText(outcome, failed) {
15
19
  const m = outcome.mutations;
16
20
 
17
21
  if (!m) return "";
18
- const external = m.external ? "; external calls attempted=" + m.external + ", their side effects cannot be rolled back" : "";
22
+ const external = failed && m.external ? "; external calls attempted=" + m.external + ", their side effects cannot be rolled back" : "";
19
23
  const uncertain = m.pendingCommits || m.recoveryFailed ? "; filesystem outcome uncertain: inspect disk and any recovery backups before retrying" : "";
20
24
 
21
- return "\nmutations: committed=" + m.committed + " rolledBack=" + m.rolledBack + " (file versions)" + external + uncertain;
25
+ if (!m.committed && !m.rolledBack && !external && !uncertain) return "";
26
+
27
+ const saved = failed && outcome.savedPaths?.length ? "\nSaved edits: " + truncateChars(JSON.stringify(outcome.savedPaths), 2048, "saved paths").text : "";
28
+ const retry = failed && m.committed && !uncertain ? saved + "\nDo not repeat saved edits. Retry only failed or not-run work; dependent JavaScript stops at the uncaught error." : saved;
29
+
30
+ return "\nmutations: committed=" + (m.committed || 0) + " rolledBack=" + (m.rolledBack || 0) + " (file versions)" + external + uncertain + retry;
22
31
  }
23
32
 
24
33
  function mutationReceipts(trace) {
@@ -39,17 +48,15 @@ function splitTurnHint(outcome) {
39
48
  }
40
49
 
41
50
  function errorText(outcome, call) {
42
- return `error #${call} ${outcome.wallMs}ms${outcome.returnTruncated ? " [output truncated]" : ""}${mutationText(outcome)}${splitTurnHint(outcome)}
43
- error: ${outcome.error}${logsBlock(outcome)}`;
51
+ return escapeUnpairedSurrogates(`error #${call} ${outcome.wallMs}ms${outcome.returnTruncated ? " [output truncated]" : ""}${mutationText(outcome, true)}${splitTurnHint(outcome)}
52
+ error: ${outcome.error}${logsBlock(outcome)}`);
44
53
  }
45
54
 
46
55
  function successText(outcome, call) {
47
56
  const truncated = outcome.returnTruncated ? " [return truncated]" : "";
48
57
  const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
49
- const m = outcome.mutations;
50
- const showMutations = m && (m.committed || m.rolledBack || m.external || m.pendingCommits || m.recoveryFailed) ? mutationText(outcome) : "";
51
58
 
52
- return `ok #${call} ${outcome.wallMs}ms${truncated}${showMutations}${splitTurnHint(outcome)}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
59
+ return `ok #${call} ${outcome.wallMs}ms${truncated}${mutationText(outcome, false)}${splitTurnHint(outcome)}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
53
60
  }
54
61
 
55
62
  function fitOutput(outcome, call, limit, format) {
@@ -72,6 +79,14 @@ function fitOutput(outcome, call, limit, format) {
72
79
  }
73
80
 
74
81
  function attachReceipts(outcome, trace) {
82
+ const saved = new Set();
83
+
84
+ for (const row of trace) {
85
+ if (row.mutationState === "saved" && isString(row.args?.path)) saved.add(row.args.path);
86
+ }
87
+
88
+ outcome.savedPaths = [...saved];
89
+
75
90
  if (outcome.ok && outcome.result === undefined) {
76
91
  const receipts = mutationReceipts(trace);
77
92
 
@@ -88,4 +103,5 @@ function throwIfFailed(outcome, visible, response) {
88
103
  Object.defineProperty(error,"supernovaResult",{value:response});
89
104
  throw error;
90
105
  }
106
+
91
107
  export { result, errorText, successText, fitOutput, attachReceipts, throwIfFailed };
@@ -2,6 +2,7 @@ import {isObject,isString} from '../shared/decode.js';
2
2
 
3
3
  function assertProgramEntry(p, defaults = {}) {
4
4
  const source = p?.code === undefined && p?.file === undefined ? defaults : p;
5
+
5
6
  if (!objectData(p) || Object.keys(p).some(key => !["code", "file", "data"].includes(key)) || !validProgramSource(source)) {
6
7
  throw new Error("each program requires code OR file (own or shared), with optional data; no nested batches or per-entry timeouts; no programs ran");
7
8
  }
@@ -30,11 +31,17 @@ function applyBatchDefaults(parsed, mergeData) {
30
31
 
31
32
  function parseBatchPayload(params, config) {
32
33
  assertBatchOptions(params);
33
- const defaults = Object.fromEntries(["code", "file", "data"].filter(key => params[key] !== undefined).map(key => [key, params[key]]));
34
+ const defaults = Object.fromEntries(["code", "file", "data"].flatMap(key => (params[key] !== undefined ? [[key, params[key]]] : [])));
35
+
34
36
  if (defaults.code !== undefined || defaults.file !== undefined) assertProgramEntry(defaults);
37
+
35
38
  for (const p of params.programs) assertProgramEntry(p, defaults);
36
- // Validate before serialization and again after snapshotting: toJSON may change an entry.
37
- return applyBatchDefaults(snapshotBatch(params, defaults, config), params.mergeData === true);
39
+
40
+ // Serialization may change the array itself as well as its entries.
41
+ const parsed = snapshotBatch(params, defaults, config);
42
+ assertBatchOptions(parsed);
43
+
44
+ return applyBatchDefaults(parsed, params.mergeData === true);
38
45
  }
39
46
 
40
47
  function batchTimeoutMs(params, config) {
@@ -47,22 +54,29 @@ function batchTimeoutMs(params, config) {
47
54
 
48
55
  function validProgramSource(source) {
49
56
  const value = source.code ?? source.file;
57
+
50
58
  return (source.code === undefined) !== (source.file === undefined) && isString(value) && value.trim();
51
59
  }
52
60
 
53
61
  function assertBatchOptions(params) {
54
62
  if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
63
+
55
64
  if (params.mergeData !== undefined && ![true, false].includes(params.mergeData)) throw new Error("mergeData must be boolean; no programs ran");
56
65
  }
57
66
 
58
67
  function snapshotBatch(params, defaults, config) {
59
68
  const hasDefaults = Object.keys(defaults).length > 0;
60
69
  let encoded;
70
+
61
71
  try { encoded = JSON.stringify(hasDefaults ? {programs:params.programs,...defaults} : params.programs); }
62
72
  catch { throw new Error("programs and defaults must be JSON-serializable; no programs ran"); }
73
+
63
74
  if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget (including shared code/file/data); no programs ran");
64
75
  const parsed = hasDefaults ? JSON.parse(encoded) : {programs:JSON.parse(encoded)};
76
+
65
77
  if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
78
+
66
79
  return parsed;
67
80
  }
81
+
68
82
  export { parseBatchPayload, batchTimeoutMs };
@@ -47,6 +47,14 @@ function leanEnvelope(res) {
47
47
  return res;
48
48
  }
49
49
 
50
+ // RPC and coalesced-read rejection callbacks run outside the awaiting guest's
51
+ // stack. Recreate their error at this async boundary, only on failure, so V8 can
52
+ // retain the guest await frames. Do not capture stacks on successful calls.
53
+ async function withGuestStack(promise) {
54
+ try { return await promise; }
55
+ catch (error) { throw new Error(error.message, { cause: error.cause }); }
56
+ }
57
+
50
58
  function swallow(promise) {
51
59
  promise.catch(() => {});
52
60
 
@@ -192,7 +200,10 @@ function formatBashFailure(command, res) {
192
200
  }
193
201
 
194
202
  function markTruncatedOutput(res, text) {
195
- if (res?.truncated && isString(text) && !text.includes("truncated")) return text + "\n…[output truncated]…";
203
+ // A literal word such as a variable name is not a truncation disclosure.
204
+ const marker = "…[output truncated]…";
205
+
206
+ if (res?.truncated && isString(text) && !text.endsWith(marker)) return text + "\n" + marker;
196
207
 
197
208
  return text;
198
209
  }
@@ -240,7 +251,7 @@ export function buildGuestApi(rpc, batchRead) {
240
251
  const invoke = (name, args) => {
241
252
  assertScope(); flushReads();
242
253
 
243
- return swallow(rpc("call", [name, args]).then(leanEnvelope));
254
+ return swallow(withGuestStack(rpc("call", [name, args]).then(leanEnvelope)));
244
255
  };
245
256
 
246
257
  const read = async (p, a, b) => {
@@ -250,9 +261,13 @@ export function buildGuestApi(rpc, batchRead) {
250
261
 
251
262
  if (Array.isArray(p)) return await readManyPaths(read, args, p);
252
263
 
253
- return !batchRead
254
- ? unwrapRead(await invoke("read", args), args, noteReadPath)
255
- : await enqueueCompatibleRead(readState, flushReads, args);
264
+ // Preserve streamed-item timing by restoring failures in this existing
265
+ // async boundary, not through another successful-path Promise hop.
266
+ try {
267
+ return !batchRead
268
+ ? unwrapRead(await invoke("read", args), args, noteReadPath)
269
+ : await enqueueCompatibleRead(readState, flushReads, args);
270
+ } catch (error) { throw new Error(error.message, { cause: error.cause }); }
256
271
  };
257
272
 
258
273
  const readFiles = new Set();
@@ -265,8 +280,18 @@ export function buildGuestApi(rpc, batchRead) {
265
280
  if (isString(envelope.sourcePath)) readFiles.add(envelope.sourcePath);
266
281
  }
267
282
 
268
- const write = async (p, content) => {
269
- const args = isObject(p) ? p : { path: p, content };
283
+ const write = async (p, content, options) => {
284
+ if (isObject(p) && (content !== undefined || options !== undefined)) throw new Error("write object form does not accept positional content or options");
285
+
286
+ if (options !== undefined && (!isObject(options) || Object.keys(options).some(key => !["append", "replace", "allowReadArtifacts"].includes(key)))) {
287
+ throw new Error("write options must be an object containing only append, replace, allowReadArtifacts");
288
+ }
289
+
290
+ const args = isObject(p) ? p : { ...options, path: p, content };
291
+
292
+ for (const key of ["append", "replace", "allowReadArtifacts"]) {
293
+ if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error("write " + key + " must be a boolean");
294
+ }
270
295
 
271
296
  if (args.append !== true && args.replace !== true && isString(args.path) && readFiles.has(args.path)) {
272
297
  throw new Error("file was already read this program; use edit(oldText, newText) or edit(view, ...). write({path,content,replace:true}) replaces anyway");
@@ -9,6 +9,7 @@ import { truncateChars,formatReturn,displayExceeds,formatBoundedValue } from "..
9
9
  import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
10
10
 
11
11
  const { register, registerHooks } = nodeModule;
12
+
12
13
  // heapUsed/external belong to this worker; rss includes the entire Pi/OMP host.
13
14
  const memoryUsage = process.memoryUsage.bind(process);
14
15
 
@@ -97,12 +98,16 @@ function encodeConsoleArg(a, limit) {
97
98
  const raw = truncateChars(a,limit,"log");
98
99
  const encoded = truncateChars(formatReturn(raw.text),limit,"log");
99
100
  encoded.truncated ||= raw.truncated;
101
+
100
102
  return encoded;
101
103
  }
104
+
102
105
  try {
103
106
  const plain = toPlain(a);
107
+
104
108
  if (displayExceeds(plain,limit)) return {text:formatBoundedValue(plain,limit,"log"),truncated:true};
105
109
  const encoded = JSON.stringify(plain);
110
+
106
111
  return truncateChars(encoded === undefined ? String(plain) : encoded,limit,"log");
107
112
  } catch { return truncateChars(formatReturn(String(a)),limit,"log"); }
108
113
  }
@@ -130,10 +135,13 @@ function handleRpcResult(msg) {
130
135
 
131
136
  function handleReadItem(msg) {
132
137
  const pending = pendingRpc.get(msg.id);
138
+
133
139
  if (!pending?.onItem || !runActive || msg.runId !== activeRunId) return;
134
140
  let error;
141
+
135
142
  try { pending.onItem(msg.index,msg.value); }
136
143
  catch (err) { error = errorMessage(err); pending.reject(err); pendingRpc.delete(msg.id); }
144
+
137
145
  reportMemory(activeRunId);
138
146
  post({op:"rpc:ack",id:msg.id,index:msg.index,runId:activeRunId,error});
139
147
  }
@@ -154,14 +162,19 @@ function makeConsole(runId, limits) {
154
162
 
155
163
  count++;
156
164
  let line = "", first = true;
165
+
157
166
  for (const arg of args) {
158
167
  const room = limits.maxLogLineChars-line.length-(first ? 0 : 1);
168
+
159
169
  if (room <= 0) { markTruncated(); break; }
170
+
160
171
  const encoded = encodeConsoleArg(arg,room);
172
+
161
173
  if (encoded.truncated) markTruncated();
162
174
  line += (first ? "" : " ")+encoded.text;
163
175
  first = false;
164
176
  }
177
+
165
178
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
166
179
 
167
180
  if (clipped.truncated) markTruncated();
@@ -171,10 +184,10 @@ function makeConsole(runId, limits) {
171
184
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
172
185
  }
173
186
 
174
- function postFailure(runId, err, location) {
187
+ function postFailure(runId, err, location, errorKind = err?.name) {
175
188
  runActive = false;
176
189
  const message = errorMessage(err);
177
- post({ op: "error", runId, message, location });
190
+ post({ op: "error", runId, message, location, errorKind });
178
191
  }
179
192
 
180
193
  function denyBuiltin(id) {
@@ -186,6 +199,7 @@ let sealedRealm = false;
186
199
  function sealGuestRealm() {
187
200
  if (sealedRealm) return;
188
201
  sealedRealm = true;
202
+
189
203
  if (isFunction(process.getBuiltinModule)) {
190
204
  const orig = process.getBuiltinModule.bind(process);
191
205
  process.getBuiltinModule = (id) => {
@@ -227,7 +241,7 @@ async function handleRun(msg) {
227
241
  // Existing programs may declare their own data variable; bind it only when supplied.
228
242
  compiled = compileGuest(prepared, msg.data);
229
243
  } catch (err) {
230
- postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
244
+ postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran."), undefined, err.name);
231
245
 
232
246
  return;
233
247
  }
@@ -258,7 +272,11 @@ async function handleRun(msg) {
258
272
  parentPort.on("message", (msg) => {
259
273
  if (!isObject(msg)) return;
260
274
 
261
- if (msg.op === "rpc:item") { handleReadItem(msg); return; }
275
+ if (msg.op === "rpc:item") {
276
+ handleReadItem(msg);
277
+
278
+ return;
279
+ }
262
280
 
263
281
  if (msg.op === "rpc:result") {
264
282
  handleRpcResult(msg);