pi-supernova 0.3.2 → 0.5.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/vfs.js CHANGED
@@ -4,18 +4,23 @@ import { isString } from "../shared/decode.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
 
6
6
  const VFS_CACHE_MAX = 1024;
7
+
7
8
  // Serialize validation + replacement across Supernova transactions in this host.
8
9
  let commitTail = Promise.resolve();
9
10
 
10
11
  export class CausalVfs {
11
12
  constructor(onNewFile, validateWrite) {
12
13
  this.validateWrite = validateWrite;
14
+ // Last-seen original bytes for write CAS, not a read cache. read() always
15
+ // hits disk unless an overlay is staged. Serving cache on read would be a
16
+ // false-valid against editors/git between two reads.
13
17
  this.cache = new Map();
14
18
  this.overlays = [];
15
19
  this.expected = new Map();
16
20
  this.onNewFile = onNewFile;
17
21
  this.closed = false;
18
22
  this.signal = undefined;
23
+ this.mutations = { committed: 0, rolledBack: 0, external: 0, pendingCommits: 0, recoveryFailed: false };
19
24
  }
20
25
 
21
26
  assertWritable() {
@@ -38,60 +43,110 @@ export class CausalVfs {
38
43
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
39
44
  }
40
45
 
41
- async read(target, { preserveRead = false } = {}) {
46
+ async read(target, { preserveRead = false, maxBytes } = {}) {
42
47
  const overlay = this.getOverlay(target);
43
- if (overlay !== undefined) return overlay;
48
+
49
+ if (overlay !== undefined) {
50
+ if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
51
+
52
+ return overlay;
53
+ }
54
+
44
55
  // External editors and captured tools can change a file between any two reads.
45
56
  try {
46
- const text = await fs.readFile(target, "utf8");
57
+ let text;
58
+
59
+ if (maxBytes === undefined) text = await fs.readFile(target, "utf8");
60
+ else {
61
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
62
+
63
+ try {
64
+ const stat = await file.stat();
65
+
66
+ if (!stat.isFile()) throw new Error("JSON read requires a regular file: " + target);
67
+ const tooLarge = () => new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
68
+
69
+ if (stat.size > maxBytes) throw tooLarge();
70
+ const chunks = [];
71
+ let size = 0;
72
+
73
+ for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal: this.signal })) {
74
+ size += chunk.length;
75
+
76
+ if (size > maxBytes) throw tooLarge();
77
+ chunks.push(chunk);
78
+ }
79
+
80
+ text = Buffer.concat(chunks).toString("utf8");
81
+ } finally { await file.close(); }
82
+ }
83
+
47
84
  if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
85
+
48
86
  return text;
49
87
  } catch (err) {
50
88
  this.cache.delete(target);
89
+
51
90
  if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
91
+
52
92
  if (err.code === "ENOENT") {
53
93
  const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
54
94
  missing.code = "ENOENT";
55
95
  throw missing;
56
96
  }
97
+
57
98
  throw err;
58
99
  }
59
100
  }
60
101
 
61
102
  async write(target, content) {
62
103
  this.assertWritable();
104
+
63
105
  if (!isString(content)) throw new Error("write requires string content");
106
+
64
107
  try {
65
108
  if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
66
109
  } catch (err) {
67
110
  if (err.code !== "ENOENT") throw err;
68
111
  }
112
+
69
113
  this.assertWritable();
114
+
70
115
  if (this.getOverlay(target) === undefined) {
71
116
  let original;
117
+
72
118
  try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
73
119
  catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
120
+
74
121
  this.expected.set(target, original);
75
122
  }
123
+
76
124
  this.assertWritable();
125
+
77
126
  if (this.overlays.length) {
78
127
  this.overlays.at(-1).set(target, content);
128
+
79
129
  return { speculative: true };
80
130
  }
131
+
81
132
  await this.flush(new Map([[target, content]]));
133
+
82
134
  return { speculative: false };
83
135
  }
84
136
 
85
137
  begin() {
86
138
  this.assertWritable();
87
139
  this.overlays.push(new Map());
140
+
88
141
  return this.overlays.length;
89
142
  }
90
143
 
91
144
  /** Stage every file and its backup before replacing any destination. */
92
145
  async flush(writes) {
93
- const work = commitTail.then(() => this.flushWrites(writes));
146
+ this.mutations.pendingCommits++;
147
+ const work = commitTail.then(() => this.flushWrites(writes)).finally(() => { this.mutations.pendingCommits--; });
94
148
  commitTail = work.catch(() => {});
149
+
95
150
  return work;
96
151
  }
97
152
 
@@ -100,30 +155,39 @@ export class CausalVfs {
100
155
  const targets = new Set();
101
156
  const createdDirs = [];
102
157
  let failed = false;
158
+
103
159
  try {
104
160
  for (const [logicalPath, content] of writes) {
105
161
  this.signal?.throwIfAborted();
106
162
  let target = logicalPath;
107
163
  let stat;
164
+
108
165
  try {
109
166
  target = await fs.realpath(logicalPath);
110
167
  stat = await fs.stat(target);
168
+
111
169
  if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
112
170
  } catch (err) {
113
171
  if (err.code !== "ENOENT") throw err;
114
172
  }
173
+
115
174
  await this.validateWrite?.(logicalPath);
175
+
116
176
  if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
117
177
  targets.add(target);
178
+
118
179
  if (this.expected.has(logicalPath)) {
119
180
  const current = stat ? await fs.readFile(target, "utf8") : null;
181
+
120
182
  if (current !== this.expected.get(logicalPath)) {
121
183
  throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
122
184
  }
123
185
  }
186
+
124
187
  const parent = path.dirname(target);
125
188
  const missing = [];
126
189
  let probe = parent;
190
+
127
191
  for (;;) {
128
192
  try { await fs.stat(probe); break; } catch (err) {
129
193
  if (err.code !== "ENOENT") throw err;
@@ -131,38 +195,50 @@ export class CausalVfs {
131
195
  probe = path.dirname(probe);
132
196
  }
133
197
  }
198
+
134
199
  await fs.mkdir(parent, { recursive: true });
135
200
  createdDirs.push(...missing.reverse());
136
201
  const token = ".supernova-" + randomUUID();
137
202
  const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
138
203
  staged.push(entry);
204
+
139
205
  const replacement = (async () => {
140
206
  await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
207
+
141
208
  if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
142
209
  })();
210
+
143
211
  // These touch separate staging files. Settle both before cleanup, even
144
212
  // on failure: Promise.all could leave a late backup after rollback.
145
213
  const staging = [replacement];
214
+
146
215
  if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
147
216
  const outcomes = await Promise.allSettled(staging);
148
217
  const failure = outcomes.find(outcome => outcome.status === "rejected");
218
+
149
219
  if (failure) throw failure.reason;
150
220
  }
221
+
151
222
  for (const entry of staged) {
152
223
  this.signal?.throwIfAborted();
153
224
  await fs.rename(entry.temporary, entry.target);
154
225
  entry.replaced = true;
155
226
  }
227
+
156
228
  for (const entry of staged) {
157
229
  this.setCache(entry.logicalPath, entry.content);
158
230
  this.expected.delete(entry.logicalPath);
159
231
  }
232
+
160
233
  if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
234
+ this.mutations.committed += staged.length;
161
235
  } catch (error) {
162
236
  failed = true;
163
237
  const recoveryErrors = [];
238
+
164
239
  for (const entry of staged.toReversed()) {
165
240
  if (!entry.replaced) continue;
241
+
166
242
  try {
167
243
  if (entry.existed) await fs.rename(entry.backup, entry.target);
168
244
  else await fs.unlink(entry.target);
@@ -172,8 +248,13 @@ export class CausalVfs {
172
248
  recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
173
249
  }
174
250
  }
251
+
175
252
  this.invalidateCache();
253
+
254
+ if (recoveryErrors.length) this.mutations.recoveryFailed = true;
255
+
176
256
  if (recoveryErrors.length) this.onNewFile?.(null);
257
+
177
258
  if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
178
259
  throw error;
179
260
  } finally {
@@ -181,8 +262,10 @@ export class CausalVfs {
181
262
  // A successful rename consumed the temporary path. These are known
182
263
  // files, so unlink avoids rm's extra type probe; missing files stay benign.
183
264
  if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
265
+
184
266
  if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
185
267
  }
268
+
186
269
  if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
187
270
  }
188
271
  }
@@ -190,32 +273,46 @@ export class CausalVfs {
190
273
  async commit() {
191
274
  if (!this.overlays.length) return { committed: 0, depth: 0 };
192
275
  const top = this.overlays.at(-1);
276
+
193
277
  if (this.overlays.length > 1) {
194
278
  const parent = this.overlays[this.overlays.length - 2];
279
+
195
280
  for (const [key, value] of top) parent.set(key, value);
196
281
  } else {
197
282
  await this.flush(top);
198
283
  }
284
+
199
285
  this.overlays.pop();
286
+
200
287
  return { committed: top.size, depth: this.overlays.length };
201
288
  }
202
289
 
203
290
  rollback() {
204
291
  const top = this.overlays.pop();
292
+ this.mutations.rolledBack += top?.size ?? 0;
293
+
205
294
  for (const target of top?.keys() ?? []) {
206
295
  if (this.getOverlay(target) === undefined) this.expected.delete(target);
207
296
  }
297
+
208
298
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
209
299
  }
210
300
 
211
301
  async prepareExternalMutation(name) {
212
302
  this.assertWritable();
303
+
213
304
  if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
214
- if (!this.overlays.length) return false;
305
+
306
+ if (!this.overlays.length) { this.mutations.external++;
307
+
308
+ return false; }
309
+
215
310
  const pending = this.overlays[0];
216
311
  await this.flush(pending);
217
312
  this.assertWritable();
218
313
  this.overlays[0] = new Map();
314
+ this.mutations.external++;
315
+
219
316
  return pending.size > 0;
220
317
  }
221
318
 
@@ -5,10 +5,14 @@ import { constants } from "node:os";
5
5
  import { isString } from "../shared/decode.js";
6
6
 
7
7
  let cachedCwd = null;
8
+
8
9
  let cachedResolvedCwd = null;
10
+
9
11
  // realpath results per program: two syscalls per call otherwise dominate a cached read.
10
12
  const realRoots = new Map();
13
+
11
14
  const realNearest = new Map();
15
+
12
16
  const PATH_CACHE_MAX = 2048;
13
17
 
14
18
  export function clearPathCache() {
@@ -19,6 +23,7 @@ function getResolvedCwd(cwd) {
19
23
  if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
20
24
  cachedCwd = cwd;
21
25
  cachedResolvedCwd = path.resolve(cwd);
26
+
22
27
  return cachedResolvedCwd;
23
28
  }
24
29
 
@@ -30,12 +35,14 @@ function assertInside(rel, message) {
30
35
 
31
36
  async function realpathNearest(target) {
32
37
  let probe = target;
38
+
33
39
  while (true) {
34
40
  try {
35
41
  return await fs.realpath(probe);
36
42
  } catch (err) {
37
43
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
38
44
  const parent = path.dirname(probe);
45
+
39
46
  if (parent === probe) throw err;
40
47
  probe = parent;
41
48
  }
@@ -53,32 +60,66 @@ const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "spec"]);
53
60
  export function isTestPath(filePath) {
54
61
  const segments = filePath.split(/[\\/]/);
55
62
  const base = segments[segments.length - 1];
63
+
56
64
  return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
57
65
  }
58
66
 
59
- export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
67
+ /** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
68
+ export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
60
69
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
61
70
  throw new Error(`${opName} requires path`);
62
71
  }
63
- if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
72
+
73
+ const trimmed = inputPath.trim();
74
+
75
+ if (/^(?:agent|artifact):\/\//i.test(trimmed)) {
76
+ if (allowSessionRead) return trimmed;
77
+ throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
78
+ }
79
+
80
+ const uri = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/.exec(trimmed);
81
+
82
+ if (uri) {
83
+ const scheme = uri[1];
84
+ const rest = uri[2];
85
+ const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
86
+
87
+ if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
88
+ throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
89
+ }
90
+ }
91
+
92
+ return trimmed;
93
+ }
94
+
95
+ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
96
+ const trimmed = assertFilesystemPath(inputPath, opName);
64
97
  const resolvedCwd = getResolvedCwd(cwd);
65
- const target = path.resolve(resolvedCwd, inputPath.trim());
98
+ const target = path.resolve(resolvedCwd, trimmed);
66
99
  assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
100
+
67
101
  if (!allowRoot && target === resolvedCwd) {
68
102
  throw new Error(`${opName} path cannot be the workspace root directory`);
69
103
  }
104
+
70
105
  let realRoot = realRoots.get(resolvedCwd);
106
+
71
107
  if (!realRoot) {
72
108
  realRoot = await fs.realpath(resolvedCwd);
73
109
  realRoots.set(resolvedCwd, realRoot);
74
110
  }
111
+
75
112
  let probe = fresh ? undefined : realNearest.get(target);
113
+
76
114
  if (!probe) {
77
115
  probe = await realpathNearest(target);
116
+
78
117
  if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
79
118
  realNearest.set(target, probe);
80
119
  }
120
+
81
121
  assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
122
+
82
123
  return target;
83
124
  }
84
125
 
@@ -87,21 +128,25 @@ export async function runCommand(argv, options = {}) {
87
128
  const cwd = options.cwd || process.cwd();
88
129
  const timeoutMs = options.timeoutMs ?? 60_000;
89
130
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
131
+
90
132
  return new Promise((resolve, reject) => {
91
133
  const child = spawn(argv[0], argv.slice(1), {
92
134
  cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
93
135
  });
136
+
94
137
  let stdout = "";
95
138
  let stderr = "";
96
139
  let settled = false;
97
140
  let outputTruncated = false;
98
141
  let terminationError;
99
142
  let escalation;
143
+
100
144
  const cleanup = () => {
101
145
  clearTimeout(timer);
102
146
  clearTimeout(escalation);
103
147
  options.signal?.removeEventListener("abort", onAbort);
104
148
  };
149
+
105
150
  const fail = error => {
106
151
  if (settled) return;
107
152
  settled = true;
@@ -111,12 +156,16 @@ export async function runCommand(argv, options = {}) {
111
156
  error.stderr = stderr;
112
157
  error.outputTruncated = outputTruncated;
113
158
  const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
159
+
114
160
  if (output) error.message += "\n" + output;
161
+
115
162
  if (outputTruncated) error.message += "\n[output truncated]";
116
163
  reject(error);
117
164
  };
165
+
118
166
  const signalTree = signal => {
119
167
  if (!child.pid) return;
168
+
120
169
  if (process.platform === "win32") {
121
170
  const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
122
171
  killer.on("error", () => child.kill(signal));
@@ -124,6 +173,7 @@ export async function runCommand(argv, options = {}) {
124
173
  try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
125
174
  }
126
175
  };
176
+
127
177
  const terminate = error => {
128
178
  if (settled || terminationError) return;
129
179
  terminationError = error;
@@ -131,13 +181,18 @@ export async function runCommand(argv, options = {}) {
131
181
  // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
132
182
  escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
133
183
  };
184
+
134
185
  const onAbort = () => terminate(new Error("aborted"));
135
186
  const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
187
+
136
188
  const append = (current, chunk) => {
137
189
  const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
190
+
138
191
  if (chunk.length > remaining) outputTruncated = true;
192
+
139
193
  return remaining ? current + chunk.slice(0, remaining) : current;
140
194
  };
195
+
141
196
  child.stdout.setEncoding("utf8");
142
197
  child.stderr.setEncoding("utf8");
143
198
  child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
@@ -145,6 +200,7 @@ export async function runCommand(argv, options = {}) {
145
200
  child.on("error", fail);
146
201
  child.on("close", (code, signal) => {
147
202
  if (settled) return;
203
+
148
204
  if (terminationError) {
149
205
  // A closed pipe alone says nothing about descendants. Only ESRCH proves
150
206
  // the owned POSIX group is gone; otherwise retain the escalation timer.
@@ -152,13 +208,16 @@ export async function runCommand(argv, options = {}) {
152
208
  try { process.kill(-child.pid, 0); }
153
209
  catch (error) { if (error.code === "ESRCH") fail(terminationError); }
154
210
  }
211
+
155
212
  return;
156
213
  }
214
+
157
215
  settled = true;
158
216
  cleanup();
159
217
  resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
160
218
  });
161
219
  options.signal?.addEventListener("abort", onAbort, { once: true });
220
+
162
221
  if (options.signal?.aborted) onAbort();
163
222
  });
164
223
  }
@@ -2,66 +2,92 @@ 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)); }
9
9
  }
10
+
10
11
  function detailsOf(raw) {
11
12
  const details = raw?.details;
13
+
12
14
  if (!isString(details)) return details;
15
+
13
16
  try { return JSON.parse(details); } catch { return details; }
14
17
  }
18
+
15
19
  export function hostResultFailed(raw) {
16
20
  const details = detailsOf(raw);
21
+
17
22
  return raw?.isError === true || details?.ok === false || (Number.isInteger(details?.exitCode) && details.exitCode !== 0);
18
23
  }
24
+
19
25
  function extractRawString(raw) {
20
26
  if (raw == null) return "";
27
+
21
28
  if (isString(raw)) return raw;
29
+
22
30
  if (!isObject(raw)) return String(raw);
31
+
23
32
  if (Array.isArray(raw.content)) return raw.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n");
33
+
24
34
  if (isString(raw.text)) return raw.text;
35
+
25
36
  return json(raw);
26
37
  }
27
38
 
28
39
  /** Bound JSON before serialization; preserve small scalar fields such as exitCode. */
29
40
  function summarizeDetails(value, budget = 2000) {
30
41
  const encoded = json(value);
42
+
31
43
  if (encoded.length <= budget) return encoded;
32
44
  const snapshot = JSON.parse(encoded);
45
+
33
46
  const fit = (input, limit) => {
34
47
  const serialized = json(input);
48
+
35
49
  if (serialized.length <= limit) return input;
50
+
36
51
  if (isString(input)) {
37
52
  let low = 0;
38
53
  let high = Math.min(input.length, limit);
54
+
39
55
  while (low < high) {
40
56
  const mid = Math.ceil((low + high) / 2);
57
+
41
58
  if (json(truncateChars(input, mid).text).length <= limit) low = mid;
42
59
  else high = mid - 1;
43
60
  }
61
+
44
62
  return truncateChars(input, low).text;
45
63
  }
64
+
46
65
  if (!isObject(input) && !Array.isArray(input)) return null;
47
66
  const out = Array.isArray(input) ? [] : { truncated: true };
48
67
  const entries = Object.entries(input);
68
+
49
69
  if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
70
+
50
71
  for (const [key, child] of entries) {
51
72
  const used = json(out).length;
52
73
  const overhead = Array.isArray(out) ? 1 : json(key).length + 2;
53
74
  const available = limit - used - overhead;
75
+
54
76
  if (available < 4) break;
55
77
  const bounded = fit(child, available);
78
+
56
79
  if (Array.isArray(out)) out.push(bounded);
57
80
  else Object.defineProperty(out, key, { value: bounded, enumerable: true, configurable: true });
81
+
58
82
  if (json(out).length > limit) {
59
83
  if (Array.isArray(out)) out.pop();
60
84
  else delete out[key];
61
85
  }
62
86
  }
87
+
63
88
  return out;
64
89
  };
90
+
65
91
  return json(fit(snapshot, budget));
66
92
  }
67
93
 
@@ -70,6 +96,7 @@ function spill(fullText, config) {
70
96
  fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
71
97
  const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
72
98
  fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
99
+
73
100
  return file;
74
101
  }
75
102
 
@@ -82,7 +109,9 @@ export function packageHostResult(raw, config) {
82
109
  let truncated = capped.truncated || details?.outputTruncated === true;
83
110
  const image = raw?.content?.find(part => part?.type === "image");
84
111
  const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
112
+
85
113
  if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
114
+
86
115
  if (batch) {
87
116
  result.itemErrors = (details.itemErrors ?? []).map(error => error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batch.length)), "error").text);
88
117
  let remaining = maxChars;
@@ -91,16 +120,22 @@ export function packageHostResult(raw, config) {
91
120
  const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
92
121
  remaining -= bounded.text.length;
93
122
  truncated ||= bounded.truncated;
123
+
94
124
  return bounded.text;
95
125
  });
96
126
  }
127
+
97
128
  result.truncated = truncated;
129
+
98
130
  if (truncated) {
99
131
  result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
132
+
100
133
  if (config.spillDir) {
101
134
  const pointer = spill(batch ? batch.join("\n---\n") : text, config);
135
+
102
136
  if (pointer) {
103
137
  result.spill = pointer;
138
+
104
139
  if (!batch) {
105
140
  const footer = "\n[full output spilled to " + pointer + "]";
106
141
  result.value = footer.length <= maxChars
@@ -110,32 +145,56 @@ export function packageHostResult(raw, config) {
110
145
  }
111
146
  }
112
147
  }
148
+
113
149
  return result;
114
150
  }
115
151
 
116
152
  export function packageFinalReturn(value, logs, config) {
117
153
  const images = [];
118
154
  let imageBytes = 0;
155
+ let imageOverflow = false;
156
+
119
157
  const collect = input => {
120
158
  if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
121
- imageBytes += Buffer.byteLength(input.data, "base64");
122
- if (images.length >= 16 || imageBytes > 20 * 1024 * 1024) throw new Error("returned images exceed 16 attachments or 20 MiB; return fewer or smaller images");
159
+ const size = Buffer.byteLength(input.data, "base64");
160
+
161
+ if (images.length >= 16 || imageBytes + size > 20 * 1024 * 1024) {
162
+ imageOverflow = true;
163
+
164
+ return "[image omitted: exceeds 16 attachments or 20 MiB]";
165
+ }
166
+
167
+ imageBytes += size;
123
168
  images.push({ type: "image", data: input.data, mimeType: input.mimeType });
169
+
124
170
  return `[image ${images.length}: ${input.mimeType}]`;
125
171
  }
172
+
126
173
  if (Array.isArray(input)) return input.map(collect);
174
+
127
175
  if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
176
+
128
177
  return input;
129
178
  };
179
+
130
180
  value = collect(value);
131
- const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
181
+ const maxReturn = config.maxReturnChars ?? 32000;
182
+ const formatted = formatReturn(value);
183
+ const serialized = formatted.length <= maxReturn
184
+ ? { text: formatted, truncated: imageOverflow }
185
+ : Array.isArray(value) && value.length && value.every(isString)
186
+ ? { text: formatBoundedStringArray(value, maxReturn), truncated: true }
187
+ : { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
132
188
  const maxLines = config.maxLogLines ?? 100;
133
189
  let logTruncated = logs.length > maxLines;
190
+
134
191
  const clipped = logs.slice(0, maxLines).map(line => {
135
192
  const result = truncateChars(line, config.maxLogLineChars ?? 4096, "log");
136
193
  logTruncated ||= result.truncated;
194
+
137
195
  return result.text;
138
196
  });
197
+
139
198
  return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
140
199
  returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
141
200
  }