pi-supernova 0.3.2 → 0.4.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,6 +4,7 @@ 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
 
@@ -16,6 +17,7 @@ export class CausalVfs {
16
17
  this.onNewFile = onNewFile;
17
18
  this.closed = false;
18
19
  this.signal = undefined;
20
+ this.mutations = { committed: 0, rolledBack: 0, external: 0, pendingCommits: 0, recoveryFailed: false };
19
21
  }
20
22
 
21
23
  assertWritable() {
@@ -38,60 +40,110 @@ export class CausalVfs {
38
40
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
39
41
  }
40
42
 
41
- async read(target, { preserveRead = false } = {}) {
43
+ async read(target, { preserveRead = false, maxBytes } = {}) {
42
44
  const overlay = this.getOverlay(target);
43
- if (overlay !== undefined) return overlay;
45
+
46
+ 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");
48
+
49
+ return overlay;
50
+ }
51
+
44
52
  // External editors and captured tools can change a file between any two reads.
45
53
  try {
46
- const text = await fs.readFile(target, "utf8");
54
+ let text;
55
+
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));
59
+
60
+ try {
61
+ const stat = await file.stat();
62
+
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");
65
+
66
+ if (stat.size > maxBytes) throw tooLarge();
67
+ const chunks = [];
68
+ let size = 0;
69
+
70
+ for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal: this.signal })) {
71
+ size += chunk.length;
72
+
73
+ if (size > maxBytes) throw tooLarge();
74
+ chunks.push(chunk);
75
+ }
76
+
77
+ text = Buffer.concat(chunks).toString("utf8");
78
+ } finally { await file.close(); }
79
+ }
80
+
47
81
  if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
82
+
48
83
  return text;
49
84
  } catch (err) {
50
85
  this.cache.delete(target);
86
+
51
87
  if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
88
+
52
89
  if (err.code === "ENOENT") {
53
90
  const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
54
91
  missing.code = "ENOENT";
55
92
  throw missing;
56
93
  }
94
+
57
95
  throw err;
58
96
  }
59
97
  }
60
98
 
61
99
  async write(target, content) {
62
100
  this.assertWritable();
101
+
63
102
  if (!isString(content)) throw new Error("write requires string content");
103
+
64
104
  try {
65
105
  if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
66
106
  } catch (err) {
67
107
  if (err.code !== "ENOENT") throw err;
68
108
  }
109
+
69
110
  this.assertWritable();
111
+
70
112
  if (this.getOverlay(target) === undefined) {
71
113
  let original;
114
+
72
115
  try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
73
116
  catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
117
+
74
118
  this.expected.set(target, original);
75
119
  }
120
+
76
121
  this.assertWritable();
122
+
77
123
  if (this.overlays.length) {
78
124
  this.overlays.at(-1).set(target, content);
125
+
79
126
  return { speculative: true };
80
127
  }
128
+
81
129
  await this.flush(new Map([[target, content]]));
130
+
82
131
  return { speculative: false };
83
132
  }
84
133
 
85
134
  begin() {
86
135
  this.assertWritable();
87
136
  this.overlays.push(new Map());
137
+
88
138
  return this.overlays.length;
89
139
  }
90
140
 
91
141
  /** Stage every file and its backup before replacing any destination. */
92
142
  async flush(writes) {
93
- const work = commitTail.then(() => this.flushWrites(writes));
143
+ this.mutations.pendingCommits++;
144
+ const work = commitTail.then(() => this.flushWrites(writes)).finally(() => { this.mutations.pendingCommits--; });
94
145
  commitTail = work.catch(() => {});
146
+
95
147
  return work;
96
148
  }
97
149
 
@@ -100,30 +152,39 @@ export class CausalVfs {
100
152
  const targets = new Set();
101
153
  const createdDirs = [];
102
154
  let failed = false;
155
+
103
156
  try {
104
157
  for (const [logicalPath, content] of writes) {
105
158
  this.signal?.throwIfAborted();
106
159
  let target = logicalPath;
107
160
  let stat;
161
+
108
162
  try {
109
163
  target = await fs.realpath(logicalPath);
110
164
  stat = await fs.stat(target);
165
+
111
166
  if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
112
167
  } catch (err) {
113
168
  if (err.code !== "ENOENT") throw err;
114
169
  }
170
+
115
171
  await this.validateWrite?.(logicalPath);
172
+
116
173
  if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
117
174
  targets.add(target);
175
+
118
176
  if (this.expected.has(logicalPath)) {
119
177
  const current = stat ? await fs.readFile(target, "utf8") : null;
178
+
120
179
  if (current !== this.expected.get(logicalPath)) {
121
180
  throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
122
181
  }
123
182
  }
183
+
124
184
  const parent = path.dirname(target);
125
185
  const missing = [];
126
186
  let probe = parent;
187
+
127
188
  for (;;) {
128
189
  try { await fs.stat(probe); break; } catch (err) {
129
190
  if (err.code !== "ENOENT") throw err;
@@ -131,38 +192,50 @@ export class CausalVfs {
131
192
  probe = path.dirname(probe);
132
193
  }
133
194
  }
195
+
134
196
  await fs.mkdir(parent, { recursive: true });
135
197
  createdDirs.push(...missing.reverse());
136
198
  const token = ".supernova-" + randomUUID();
137
199
  const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
138
200
  staged.push(entry);
201
+
139
202
  const replacement = (async () => {
140
203
  await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
204
+
141
205
  if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
142
206
  })();
207
+
143
208
  // These touch separate staging files. Settle both before cleanup, even
144
209
  // on failure: Promise.all could leave a late backup after rollback.
145
210
  const staging = [replacement];
211
+
146
212
  if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
147
213
  const outcomes = await Promise.allSettled(staging);
148
214
  const failure = outcomes.find(outcome => outcome.status === "rejected");
215
+
149
216
  if (failure) throw failure.reason;
150
217
  }
218
+
151
219
  for (const entry of staged) {
152
220
  this.signal?.throwIfAborted();
153
221
  await fs.rename(entry.temporary, entry.target);
154
222
  entry.replaced = true;
155
223
  }
224
+
156
225
  for (const entry of staged) {
157
226
  this.setCache(entry.logicalPath, entry.content);
158
227
  this.expected.delete(entry.logicalPath);
159
228
  }
229
+
160
230
  if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
231
+ this.mutations.committed += staged.length;
161
232
  } catch (error) {
162
233
  failed = true;
163
234
  const recoveryErrors = [];
235
+
164
236
  for (const entry of staged.toReversed()) {
165
237
  if (!entry.replaced) continue;
238
+
166
239
  try {
167
240
  if (entry.existed) await fs.rename(entry.backup, entry.target);
168
241
  else await fs.unlink(entry.target);
@@ -172,8 +245,13 @@ export class CausalVfs {
172
245
  recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
173
246
  }
174
247
  }
248
+
175
249
  this.invalidateCache();
250
+
251
+ if (recoveryErrors.length) this.mutations.recoveryFailed = true;
252
+
176
253
  if (recoveryErrors.length) this.onNewFile?.(null);
254
+
177
255
  if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
178
256
  throw error;
179
257
  } finally {
@@ -181,8 +259,10 @@ export class CausalVfs {
181
259
  // A successful rename consumed the temporary path. These are known
182
260
  // files, so unlink avoids rm's extra type probe; missing files stay benign.
183
261
  if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
262
+
184
263
  if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
185
264
  }
265
+
186
266
  if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
187
267
  }
188
268
  }
@@ -190,32 +270,46 @@ export class CausalVfs {
190
270
  async commit() {
191
271
  if (!this.overlays.length) return { committed: 0, depth: 0 };
192
272
  const top = this.overlays.at(-1);
273
+
193
274
  if (this.overlays.length > 1) {
194
275
  const parent = this.overlays[this.overlays.length - 2];
276
+
195
277
  for (const [key, value] of top) parent.set(key, value);
196
278
  } else {
197
279
  await this.flush(top);
198
280
  }
281
+
199
282
  this.overlays.pop();
283
+
200
284
  return { committed: top.size, depth: this.overlays.length };
201
285
  }
202
286
 
203
287
  rollback() {
204
288
  const top = this.overlays.pop();
289
+ this.mutations.rolledBack += top?.size ?? 0;
290
+
205
291
  for (const target of top?.keys() ?? []) {
206
292
  if (this.getOverlay(target) === undefined) this.expected.delete(target);
207
293
  }
294
+
208
295
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
209
296
  }
210
297
 
211
298
  async prepareExternalMutation(name) {
212
299
  this.assertWritable();
300
+
213
301
  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;
302
+
303
+ if (!this.overlays.length) { this.mutations.external++;
304
+
305
+ return false; }
306
+
215
307
  const pending = this.overlays[0];
216
308
  await this.flush(pending);
217
309
  this.assertWritable();
218
310
  this.overlays[0] = new Map();
311
+ this.mutations.external++;
312
+
219
313
  return pending.size > 0;
220
314
  }
221
315
 
@@ -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,6 +60,7 @@ 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
 
@@ -60,25 +68,34 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
60
68
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
61
69
  throw new Error(`${opName} requires path`);
62
70
  }
71
+
63
72
  if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
64
73
  const resolvedCwd = getResolvedCwd(cwd);
65
74
  const target = path.resolve(resolvedCwd, inputPath.trim());
66
75
  assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
76
+
67
77
  if (!allowRoot && target === resolvedCwd) {
68
78
  throw new Error(`${opName} path cannot be the workspace root directory`);
69
79
  }
80
+
70
81
  let realRoot = realRoots.get(resolvedCwd);
82
+
71
83
  if (!realRoot) {
72
84
  realRoot = await fs.realpath(resolvedCwd);
73
85
  realRoots.set(resolvedCwd, realRoot);
74
86
  }
87
+
75
88
  let probe = fresh ? undefined : realNearest.get(target);
89
+
76
90
  if (!probe) {
77
91
  probe = await realpathNearest(target);
92
+
78
93
  if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
79
94
  realNearest.set(target, probe);
80
95
  }
96
+
81
97
  assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
98
+
82
99
  return target;
83
100
  }
84
101
 
@@ -87,21 +104,25 @@ export async function runCommand(argv, options = {}) {
87
104
  const cwd = options.cwd || process.cwd();
88
105
  const timeoutMs = options.timeoutMs ?? 60_000;
89
106
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
107
+
90
108
  return new Promise((resolve, reject) => {
91
109
  const child = spawn(argv[0], argv.slice(1), {
92
110
  cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
93
111
  });
112
+
94
113
  let stdout = "";
95
114
  let stderr = "";
96
115
  let settled = false;
97
116
  let outputTruncated = false;
98
117
  let terminationError;
99
118
  let escalation;
119
+
100
120
  const cleanup = () => {
101
121
  clearTimeout(timer);
102
122
  clearTimeout(escalation);
103
123
  options.signal?.removeEventListener("abort", onAbort);
104
124
  };
125
+
105
126
  const fail = error => {
106
127
  if (settled) return;
107
128
  settled = true;
@@ -111,12 +132,16 @@ export async function runCommand(argv, options = {}) {
111
132
  error.stderr = stderr;
112
133
  error.outputTruncated = outputTruncated;
113
134
  const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
135
+
114
136
  if (output) error.message += "\n" + output;
137
+
115
138
  if (outputTruncated) error.message += "\n[output truncated]";
116
139
  reject(error);
117
140
  };
141
+
118
142
  const signalTree = signal => {
119
143
  if (!child.pid) return;
144
+
120
145
  if (process.platform === "win32") {
121
146
  const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
122
147
  killer.on("error", () => child.kill(signal));
@@ -124,6 +149,7 @@ export async function runCommand(argv, options = {}) {
124
149
  try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
125
150
  }
126
151
  };
152
+
127
153
  const terminate = error => {
128
154
  if (settled || terminationError) return;
129
155
  terminationError = error;
@@ -131,13 +157,18 @@ export async function runCommand(argv, options = {}) {
131
157
  // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
132
158
  escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
133
159
  };
160
+
134
161
  const onAbort = () => terminate(new Error("aborted"));
135
162
  const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
163
+
136
164
  const append = (current, chunk) => {
137
165
  const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
166
+
138
167
  if (chunk.length > remaining) outputTruncated = true;
168
+
139
169
  return remaining ? current + chunk.slice(0, remaining) : current;
140
170
  };
171
+
141
172
  child.stdout.setEncoding("utf8");
142
173
  child.stderr.setEncoding("utf8");
143
174
  child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
@@ -145,6 +176,7 @@ export async function runCommand(argv, options = {}) {
145
176
  child.on("error", fail);
146
177
  child.on("close", (code, signal) => {
147
178
  if (settled) return;
179
+
148
180
  if (terminationError) {
149
181
  // A closed pipe alone says nothing about descendants. Only ESRCH proves
150
182
  // the owned POSIX group is gone; otherwise retain the escalation timer.
@@ -152,13 +184,16 @@ export async function runCommand(argv, options = {}) {
152
184
  try { process.kill(-child.pid, 0); }
153
185
  catch (error) { if (error.code === "ESRCH") fail(terminationError); }
154
186
  }
187
+
155
188
  return;
156
189
  }
190
+
157
191
  settled = true;
158
192
  cleanup();
159
193
  resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
160
194
  });
161
195
  options.signal?.addEventListener("abort", onAbort, { once: true });
196
+
162
197
  if (options.signal?.aborted) onAbort();
163
198
  });
164
199
  }
@@ -7,61 +7,87 @@ import { truncateChars, formatReturn } from "./format.js";
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,43 @@ 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
+
119
156
  const collect = input => {
120
157
  if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
121
158
  imageBytes += Buffer.byteLength(input.data, "base64");
159
+
122
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");
123
161
  images.push({ type: "image", data: input.data, mimeType: input.mimeType });
162
+
124
163
  return `[image ${images.length}: ${input.mimeType}]`;
125
164
  }
165
+
126
166
  if (Array.isArray(input)) return input.map(collect);
167
+
127
168
  if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
169
+
128
170
  return input;
129
171
  };
172
+
130
173
  value = collect(value);
131
174
  const serialized = truncateChars(formatReturn(value), config.maxReturnChars ?? 32000, "return");
132
175
  const maxLines = config.maxLogLines ?? 100;
133
176
  let logTruncated = logs.length > maxLines;
177
+
134
178
  const clipped = logs.slice(0, maxLines).map(line => {
135
179
  const result = truncateChars(line, config.maxLogLineChars ?? 4096, "log");
136
180
  logTruncated ||= result.truncated;
181
+
137
182
  return result.text;
138
183
  });
184
+
139
185
  return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
140
186
  returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
141
187
  }