pi-supernova 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fs/patch.js CHANGED
@@ -67,7 +67,9 @@ function findHunkMatch(fileLines, expectedOld, nominal) {
67
67
 
68
68
  if (!expectedOld.length) return -1;
69
69
 
70
- for (let delta = 1; delta <= Math.max(fileLines.length, 100); delta++) {
70
+ const maxDrift = Math.min(Math.max(fileLines.length, 100), 200);
71
+
72
+ for (let delta = 1; delta <= maxDrift; delta++) {
71
73
  if (matchAt(nominal + delta)) return nominal + delta;
72
74
 
73
75
  if (matchAt(nominal - delta)) return nominal - delta;
@@ -103,7 +105,7 @@ export function applyPatchToText(originalText, patchText) {
103
105
  const line = hunk.lines[i];
104
106
 
105
107
  if (line[0] === "+") {
106
- replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : line.endsWith("\r") ? "\r\n" : ending });
108
+ replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : ending });
107
109
  } else {
108
110
  const original = fileLines[oldIndex++];
109
111
 
package/src/fs/vfs.js CHANGED
@@ -1,17 +1,73 @@
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 { randomUUID } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
 
6
6
  const VFS_CACHE_MAX = 1024;
7
7
 
8
+ const VFS_CACHE_MAX_BYTES = 64 * 1024 * 1024;
9
+
8
10
  // Serialize validation + replacement across Supernova transactions in this host.
9
11
  let commitTail = Promise.resolve();
10
12
 
13
+ function textSignature(text) {
14
+ return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
15
+ }
16
+
17
+ function sameFileVersion(a, b) {
18
+ return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
19
+ }
20
+
21
+ async function fileSignature(target, signal, observed) {
22
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
23
+
24
+ try {
25
+ const actual = await file.stat();
26
+
27
+ if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
28
+ if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
29
+ const hash = createHash("sha256");
30
+
31
+ for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
32
+ const after = await file.stat();
33
+
34
+ if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
35
+
36
+ return { size: actual.size, sha256: hash.digest("hex") };
37
+ } finally {
38
+ await file.close();
39
+ }
40
+ }
41
+
42
+ // realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
43
+ // ancestor so two symlink spellings still share one commit destination.
44
+ async function canonicalNewPath(target) {
45
+ let ancestor = path.dirname(target);
46
+
47
+ for (;;) {
48
+ try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT") throw error;
51
+ const parent = path.dirname(ancestor);
52
+
53
+ if (parent === ancestor) throw error;
54
+ ancestor = parent;
55
+ }
56
+ }
57
+ }
58
+
59
+ function sameSignature(a, b) {
60
+ return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
61
+ }
62
+
11
63
  export class CausalVfs {
12
64
  constructor(onNewFile, validateWrite) {
13
65
  this.validateWrite = validateWrite;
66
+ // Optional receipt bodies, never the authority for CAS. Signatures survive
67
+ // body eviction. Explicit reads hit disk and replace the observed snapshot;
68
+ // internal receipt reads preserve it until an external-mutation boundary.
14
69
  this.cache = new Map();
70
+ this.cacheBytes = 0;
15
71
  this.overlays = [];
16
72
  this.expected = new Map();
17
73
  this.onNewFile = onNewFile;
@@ -25,9 +81,31 @@ export class CausalVfs {
25
81
  this.signal?.throwIfAborted();
26
82
  }
27
83
 
84
+ dropCache(target) {
85
+ const previous = this.cache.get(target);
86
+
87
+ if (previous !== undefined && this.cache.delete(target)) this.cacheBytes -= Buffer.byteLength(previous, "utf8");
88
+ }
89
+
28
90
  setCache(target, content) {
29
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) this.cache.delete(this.cache.keys().next().value);
91
+ const bytes = Buffer.byteLength(content, "utf8");
92
+
93
+ if (bytes > VFS_CACHE_MAX_BYTES) {
94
+ this.dropCache(target);
95
+ return;
96
+ }
97
+
98
+ this.dropCache(target);
99
+
100
+ while ((this.cache.size >= VFS_CACHE_MAX || this.cacheBytes + bytes > VFS_CACHE_MAX_BYTES) && this.cache.size) {
101
+ const oldest = this.cache.keys().next().value;
102
+
103
+ this.cacheBytes -= Buffer.byteLength(this.cache.get(oldest), "utf8");
104
+ this.cache.delete(oldest);
105
+ }
106
+
30
107
  this.cache.set(target, content);
108
+ this.cacheBytes += bytes;
31
109
  }
32
110
 
33
111
  getOverlay(target) {
@@ -40,28 +118,30 @@ export class CausalVfs {
40
118
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
41
119
  }
42
120
 
43
- async read(target, { preserveRead = false, maxBytes } = {}) {
121
+ async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
44
122
  const overlay = this.getOverlay(target);
45
123
 
46
124
  if (overlay !== undefined) {
47
- if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
125
+ if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
48
126
 
49
127
  return overlay;
50
128
  }
51
129
 
52
130
  // External editors and captured tools can change a file between any two reads.
131
+ // Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
53
132
  try {
54
- let text;
133
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
134
+ let bytes;
55
135
 
56
- if (maxBytes === undefined) text = await fs.readFile(target, "utf8");
57
- else {
58
- const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
136
+ try {
137
+ const stat = await file.stat();
59
138
 
60
- try {
61
- const stat = await file.stat();
139
+ if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
140
+ if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
62
141
 
63
- if (!stat.isFile()) throw new Error("JSON read requires a regular file: " + target);
64
- const tooLarge = () => new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
142
+ if (maxBytes === undefined) bytes = await file.readFile({ signal: this.signal });
143
+ else {
144
+ const tooLarge = () => new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
65
145
 
66
146
  if (stat.size > maxBytes) throw tooLarge();
67
147
  const chunks = [];
@@ -74,15 +154,19 @@ export class CausalVfs {
74
154
  chunks.push(chunk);
75
155
  }
76
156
 
77
- text = Buffer.concat(chunks).toString("utf8");
78
- } finally { await file.close(); }
79
- }
157
+ bytes = Buffer.concat(chunks);
158
+ }
159
+ if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
160
+ } finally { await file.close(); }
80
161
 
81
- if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
162
+ // Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
163
+ if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
164
+ const text = bytes.toString("utf8");
165
+ this.setCache(target, text);
82
166
 
83
167
  return text;
84
168
  } catch (err) {
85
- this.cache.delete(target);
169
+ this.dropCache(target);
86
170
 
87
171
  if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
88
172
 
@@ -96,6 +180,27 @@ export class CausalVfs {
96
180
  }
97
181
  }
98
182
 
183
+ async #diskSignature(target) {
184
+ let stat;
185
+
186
+ try { stat = await fs.stat(target); }
187
+ catch (error) { if (error.code !== "ENOENT") throw error; }
188
+
189
+ return stat?.isFile() ? await fileSignature(target, this.signal, stat) : null;
190
+ }
191
+
192
+ async captureExpected(target) {
193
+ if (this.getOverlay(target) !== undefined || this.expected.has(target)) return;
194
+
195
+ this.expected.set(target, await this.#diskSignature(target));
196
+ }
197
+
198
+ async recordExpected(target, observed) {
199
+ if (this.getOverlay(target) !== undefined) return;
200
+ const signature = observed ? await fileSignature(target, this.signal, observed) : await this.#diskSignature(target);
201
+ this.expected.set(target, signature);
202
+ }
203
+
99
204
  async write(target, content) {
100
205
  this.assertWritable();
101
206
 
@@ -109,14 +214,7 @@ export class CausalVfs {
109
214
 
110
215
  this.assertWritable();
111
216
 
112
- if (this.getOverlay(target) === undefined) {
113
- let original;
114
-
115
- try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
116
- catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
117
-
118
- this.expected.set(target, original);
119
- }
217
+ await this.captureExpected(target);
120
218
 
121
219
  this.assertWritable();
122
220
 
@@ -166,6 +264,7 @@ export class CausalVfs {
166
264
  if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
167
265
  } catch (err) {
168
266
  if (err.code !== "ENOENT") throw err;
267
+ target = await canonicalNewPath(logicalPath);
169
268
  }
170
269
 
171
270
  await this.validateWrite?.(logicalPath);
@@ -174,9 +273,9 @@ export class CausalVfs {
174
273
  targets.add(target);
175
274
 
176
275
  if (this.expected.has(logicalPath)) {
177
- const current = stat ? await fs.readFile(target, "utf8") : null;
276
+ const current = stat ? await fileSignature(target, this.signal) : null;
178
277
 
179
- if (current !== this.expected.get(logicalPath)) {
278
+ if (!sameSignature(current, this.expected.get(logicalPath))) {
180
279
  throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
181
280
  }
182
281
  }
@@ -224,10 +323,12 @@ export class CausalVfs {
224
323
 
225
324
  for (const entry of staged) {
226
325
  this.setCache(entry.logicalPath, entry.content);
227
- this.expected.delete(entry.logicalPath);
326
+ this.expected.set(entry.logicalPath, textSignature(entry.content));
228
327
  }
229
328
 
230
- if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
329
+ // Canonical commit destinations must not rewrite established event paths
330
+ // for newly created files, whose callers supplied a logical cwd spelling.
331
+ if (staged.length) this.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
231
332
  this.mutations.committed += staged.length;
232
333
  } catch (error) {
233
334
  failed = true;
@@ -288,9 +389,8 @@ export class CausalVfs {
288
389
  const top = this.overlays.pop();
289
390
  this.mutations.rolledBack += top?.size ?? 0;
290
391
 
291
- for (const target of top?.keys() ?? []) {
292
- if (this.getOverlay(target) === undefined) this.expected.delete(target);
293
- }
392
+ // Rolling back staged writes does not undo observations of disk. Keep the
393
+ // read snapshot, including for files without a surviving parent overlay.
294
394
 
295
395
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
296
396
  }
@@ -313,7 +413,7 @@ export class CausalVfs {
313
413
  return pending.size > 0;
314
414
  }
315
415
 
316
- invalidateCache() { this.cache.clear(); }
416
+ invalidateCache() { this.cache.clear(); this.cacheBytes = 0; this.expected.clear(); }
317
417
  getCacheSize() { return this.cache.size; }
318
418
  getOverlayDepth() { return this.overlays.length; }
319
419
  }
@@ -16,6 +16,9 @@ const realNearest = new Map();
16
16
  const PATH_CACHE_MAX = 2048;
17
17
 
18
18
  export function clearPathCache() {
19
+ cachedCwd = null;
20
+ cachedResolvedCwd = null;
21
+ realRoots.clear();
19
22
  realNearest.clear();
20
23
  }
21
24
 
@@ -64,14 +67,38 @@ export function isTestPath(filePath) {
64
67
  return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
65
68
  }
66
69
 
67
- export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
70
+ /** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
71
+ export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
68
72
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
69
73
  throw new Error(`${opName} requires path`);
70
74
  }
71
75
 
72
- if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
76
+ const trimmed = inputPath.trim();
77
+
78
+ if (/^(?:agent|artifact):\/\//i.test(trimmed)) {
79
+ if (allowSessionRead) return trimmed;
80
+ throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
81
+ }
82
+
83
+ const uri = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/.exec(trimmed);
84
+
85
+ if (uri) {
86
+ const scheme = uri[1];
87
+ const rest = uri[2];
88
+ const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
89
+
90
+ if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
91
+ throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
92
+ }
93
+ }
94
+
95
+ return trimmed;
96
+ }
97
+
98
+ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
99
+ const trimmed = assertFilesystemPath(inputPath, opName);
73
100
  const resolvedCwd = getResolvedCwd(cwd);
74
- const target = path.resolve(resolvedCwd, inputPath.trim());
101
+ const target = path.resolve(resolvedCwd, trimmed);
75
102
  assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
76
103
 
77
104
  if (!allowRoot && target === resolvedCwd) {
@@ -102,7 +129,10 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
102
129
  export async function runCommand(argv, options = {}) {
103
130
  options.signal?.throwIfAborted();
104
131
  const cwd = options.cwd || process.cwd();
105
- const timeoutMs = options.timeoutMs ?? 60_000;
132
+ const requestedTimeout = Number(options.timeoutMs === undefined ? 60_000 : options.timeoutMs);
133
+
134
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
135
+ const timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
106
136
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
107
137
 
108
138
  return new Promise((resolve, reject) => {
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { isString, isObject } from "../shared/decode.js";
5
- import { truncateChars, formatReturn } from "./format.js";
5
+ import { truncateChars, formatReturn, formatBoundedStringArray } from "./format.js";
6
6
 
7
7
  function json(value) {
8
8
  try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
@@ -19,7 +19,7 @@ function detailsOf(raw) {
19
19
  export function hostResultFailed(raw) {
20
20
  const details = detailsOf(raw);
21
21
 
22
- return raw?.isError === true || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
22
+ return raw?.isError === true || raw?.ok === false || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
23
23
  }
24
24
 
25
25
  function extractRawString(raw) {
@@ -92,12 +92,16 @@ function summarizeDetails(value, budget = 2000) {
92
92
  }
93
93
 
94
94
  function spill(fullText, config) {
95
- if (!isString(config.spillDir) || !config.spillDir) return undefined;
96
- fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
97
- const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
98
- fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
99
-
100
- return file;
95
+ try {
96
+ if (!isString(config.spillDir) || !config.spillDir) return undefined;
97
+ fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
98
+ const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
99
+ fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
100
+
101
+ return file;
102
+ } catch {
103
+ return undefined;
104
+ }
101
105
  }
102
106
 
103
107
  export function packageHostResult(raw, config) {
@@ -108,7 +112,10 @@ export function packageHostResult(raw, config) {
108
112
  const capped = truncateChars(text, maxChars, "host-result");
109
113
  let truncated = capped.truncated || details?.outputTruncated === true;
110
114
  const image = raw?.content?.find(part => part?.type === "image");
111
- const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
115
+ const directoryEntries = image === undefined && details?.directory === true && Array.isArray(details.entries) && json(details.entries).length <= maxChars
116
+ ? details.entries
117
+ : undefined;
118
+ const result = { ok: !hostResultFailed(raw), value: image ?? directoryEntries ?? capped.text, truncated };
112
119
 
113
120
  if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
114
121
 
@@ -117,7 +124,25 @@ export function packageHostResult(raw, config) {
117
124
  let remaining = maxChars;
118
125
  result.items = batch.map((item, index) => {
119
126
  if (item?.type === "image") return item;
120
- const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
127
+ const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
128
+
129
+ if (!isString(item)) {
130
+ const encoded = json(item);
131
+
132
+ if (encoded.length <= share) {
133
+ remaining -= encoded.length;
134
+
135
+ return item;
136
+ }
137
+
138
+ const bounded = truncateChars(encoded, share, "host-result");
139
+ remaining -= bounded.text.length;
140
+ truncated ||= bounded.truncated;
141
+
142
+ return bounded.text;
143
+ }
144
+
145
+ const bounded = truncateChars(item, share, "host-result");
121
146
  remaining -= bounded.text.length;
122
147
  truncated ||= bounded.truncated;
123
148
 
@@ -128,10 +153,10 @@ export function packageHostResult(raw, config) {
128
153
  result.truncated = truncated;
129
154
 
130
155
  if (truncated) {
131
- result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
156
+ result.originalChars = batch ? batch.reduce((sum, item) => sum + (isString(item) ? item.length : json(item).length), 0) : text.length;
132
157
 
133
158
  if (config.spillDir) {
134
- const pointer = spill(batch ? batch.join("\n---\n") : text, config);
159
+ const pointer = spill(batch ? batch.map(item => isString(item) ? item : json(item)).join("\n---\n") : text, config);
135
160
 
136
161
  if (pointer) {
137
162
  result.spill = pointer;
@@ -152,12 +177,19 @@ export function packageHostResult(raw, config) {
152
177
  export function packageFinalReturn(value, logs, config) {
153
178
  const images = [];
154
179
  let imageBytes = 0;
180
+ let imageOverflow = false;
155
181
 
156
182
  const collect = input => {
157
183
  if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
158
- imageBytes += Buffer.byteLength(input.data, "base64");
184
+ const size = Buffer.byteLength(input.data, "base64");
185
+
186
+ if (images.length >= 16 || imageBytes + size > 20 * 1024 * 1024) {
187
+ imageOverflow = true;
188
+
189
+ return "[image omitted: exceeds 16 attachments or 20 MiB]";
190
+ }
159
191
 
160
- if (images.length >= 16 || imageBytes > 20 * 1024 * 1024) throw new Error("returned images exceed 16 attachments or 20 MiB; return fewer or smaller images");
192
+ imageBytes += size;
161
193
  images.push({ type: "image", data: input.data, mimeType: input.mimeType });
162
194
 
163
195
  return `[image ${images.length}: ${input.mimeType}]`;
@@ -171,7 +203,13 @@ export function packageFinalReturn(value, logs, config) {
171
203
  };
172
204
 
173
205
  value = collect(value);
174
- const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
206
+ const maxReturn = config.maxReturnChars ?? 32000;
207
+ const formatted = formatReturn(value);
208
+ const serialized = formatted.length <= maxReturn
209
+ ? { text: formatted, truncated: imageOverflow }
210
+ : Array.isArray(value) && value.length && value.every(isString)
211
+ ? { text: formatBoundedStringArray(value, maxReturn), truncated: true }
212
+ : { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
175
213
  const maxLines = config.maxLogLines ?? 100;
176
214
  let logTruncated = logs.length > maxLines;
177
215
 
@@ -71,6 +71,25 @@ function hasWellFormedStrings(values) {
71
71
  return true;
72
72
  }
73
73
 
74
+ /** Keep every array item in an oversized return by giving each a fair truncated share. */
75
+ export function formatBoundedStringArray(values, budget) {
76
+ const n = values.length;
77
+ const header = "strings[" + n + "]\n";
78
+ let remaining = Math.max(0, budget - header.length);
79
+ let out = header;
80
+
81
+ for (let i = 0; i < n; i++) {
82
+ const itemHeader = "[" + i + "] " + values[i].length + " UTF-16 units\n";
83
+ const per = Math.max(32, Math.floor(remaining / (n - i)) - itemHeader.length - 1);
84
+ const bounded = truncateChars(values[i], per, "return");
85
+ const chunk = itemHeader + bounded.text + "\n";
86
+ remaining = Math.max(0, remaining - chunk.length);
87
+ out += chunk;
88
+ }
89
+
90
+ return out;
91
+ }
92
+
74
93
  /** Lossless framing for source arrays, not string escaping or source compression. */
75
94
  export function formatReturn(value) {
76
95
  if (isString(value)) return value;
@@ -124,7 +143,8 @@ function formatPrimitive(value) {
124
143
 
125
144
  if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
126
145
 
127
- return JSON.stringify(value) ?? String(value);
146
+ try { return JSON.stringify(value) ?? String(value); }
147
+ catch { return String(value); }
128
148
  }
129
149
 
130
150
  /**
@@ -149,39 +169,46 @@ function formatFlatWithin(value, limit) {
149
169
  return true;
150
170
  };
151
171
 
152
- return walkFlat(value, push) ? parts.join("") : null;
172
+ return walkFlat(value, push, new Set()) ? parts.join("") : null;
153
173
  }
154
174
 
155
- function walkFlat(value, push) {
175
+ function walkFlat(value, push, seen) {
156
176
  if (value?.[RAW_TEXT] !== undefined) return push(value[RAW_TEXT]);
157
177
 
158
178
  if (!isObject(value) && !Array.isArray(value)) return push(formatPrimitive(value));
159
179
 
160
- if (Array.isArray(value)) {
161
- if (value.length === 0) return push("[]");
180
+ if (seen.has(value)) return push("[Circular]");
181
+ seen.add(value);
162
182
 
163
- if (!push("[")) return false;
183
+ try {
184
+ if (Array.isArray(value)) {
185
+ if (value.length === 0) return push("[]");
164
186
 
165
- for (let i = 0; i < value.length; i++) {
166
- if (i && !push(",")) return false;
187
+ if (!push("[")) return false;
167
188
 
168
- if (!walkFlat(value[i] === undefined ? null : value[i], push)) return false;
189
+ for (let i = 0; i < value.length; i++) {
190
+ if (i && !push(",")) return false;
191
+
192
+ if (!walkFlat(value[i] === undefined ? null : value[i], push, seen)) return false;
193
+ }
194
+
195
+ return push("]");
169
196
  }
170
197
 
171
- return push("]");
172
- }
198
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
173
199
 
174
- const keys = Object.keys(value).filter((key) => value[key] !== undefined);
200
+ if (keys.length === 0) return push("{}");
175
201
 
176
- if (keys.length === 0) return push("{}");
202
+ for (let i = 0; i < keys.length; i++) {
203
+ if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
177
204
 
178
- for (let i = 0; i < keys.length; i++) {
179
- if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
205
+ if (!walkFlat(value[keys[i]], push, seen)) return false;
206
+ }
180
207
 
181
- if (!walkFlat(value[keys[i]], push)) return false;
208
+ return push("}");
209
+ } finally {
210
+ seen.delete(value);
182
211
  }
183
-
184
- return push("}");
185
212
  }
186
213
 
187
214
  /**
@@ -190,24 +217,31 @@ function walkFlat(value, push) {
190
217
  * indent is one space. Whitespace is what costs tokens: this measures ~43% fewer
191
218
  * than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
192
219
  */
193
- export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
220
+ export function formatValue(value, indent = "", width = FORMAT_WIDTH, seen = new Set()) {
194
221
  if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
195
222
 
196
223
  if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
197
- const flat = formatFlatWithin(value, width - indent.length);
224
+ if (seen.has(value)) return "[Circular]";
225
+ seen.add(value);
198
226
 
199
- if (flat !== null) return flat;
200
- const pad = indent + " ";
227
+ try {
228
+ const flat = formatFlatWithin(value, width - indent.length);
201
229
 
202
- if (Array.isArray(value)) {
203
- if (value.length === 0) return "[]";
230
+ if (flat !== null) return flat;
231
+ const pad = indent + " ";
204
232
 
205
- return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
206
- }
233
+ if (Array.isArray(value)) {
234
+ if (value.length === 0) return "[]";
207
235
 
208
- const keys = Object.keys(value).filter((key) => value[key] !== undefined);
236
+ return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width, seen)).join(",\n") + "\n" + indent + "]";
237
+ }
238
+
239
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined);
209
240
 
210
- if (keys.length === 0) return "{}";
241
+ if (keys.length === 0) return "{}";
211
242
 
212
- return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
243
+ return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width, seen)).join(",\n") + "\n" + indent + "}";
244
+ } finally {
245
+ seen.delete(value);
246
+ }
213
247
  }