pi-supernova 0.3.1 → 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
  }
160
- if (staged.length) this.onNewFile?.();
229
+
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,7 +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
+
253
+ if (recoveryErrors.length) this.onNewFile?.(null);
254
+
176
255
  if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
177
256
  throw error;
178
257
  } finally {
@@ -180,8 +259,10 @@ export class CausalVfs {
180
259
  // A successful rename consumed the temporary path. These are known
181
260
  // files, so unlink avoids rm's extra type probe; missing files stay benign.
182
261
  if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
262
+
183
263
  if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
184
264
  }
265
+
185
266
  if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
186
267
  }
187
268
  }
@@ -189,32 +270,46 @@ export class CausalVfs {
189
270
  async commit() {
190
271
  if (!this.overlays.length) return { committed: 0, depth: 0 };
191
272
  const top = this.overlays.at(-1);
273
+
192
274
  if (this.overlays.length > 1) {
193
275
  const parent = this.overlays[this.overlays.length - 2];
276
+
194
277
  for (const [key, value] of top) parent.set(key, value);
195
278
  } else {
196
279
  await this.flush(top);
197
280
  }
281
+
198
282
  this.overlays.pop();
283
+
199
284
  return { committed: top.size, depth: this.overlays.length };
200
285
  }
201
286
 
202
287
  rollback() {
203
288
  const top = this.overlays.pop();
289
+ this.mutations.rolledBack += top?.size ?? 0;
290
+
204
291
  for (const target of top?.keys() ?? []) {
205
292
  if (this.getOverlay(target) === undefined) this.expected.delete(target);
206
293
  }
294
+
207
295
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
208
296
  }
209
297
 
210
298
  async prepareExternalMutation(name) {
211
299
  this.assertWritable();
300
+
212
301
  if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
213
- if (!this.overlays.length) return false;
302
+
303
+ if (!this.overlays.length) { this.mutations.external++;
304
+
305
+ return false; }
306
+
214
307
  const pending = this.overlays[0];
215
308
  await this.flush(pending);
216
309
  this.assertWritable();
217
310
  this.overlays[0] = new Map();
311
+ this.mutations.external++;
312
+
218
313
  return pending.size > 0;
219
314
  }
220
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,24 +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
+
72
+ if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
63
73
  const resolvedCwd = getResolvedCwd(cwd);
64
74
  const target = path.resolve(resolvedCwd, inputPath.trim());
65
75
  assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
76
+
66
77
  if (!allowRoot && target === resolvedCwd) {
67
78
  throw new Error(`${opName} path cannot be the workspace root directory`);
68
79
  }
80
+
69
81
  let realRoot = realRoots.get(resolvedCwd);
82
+
70
83
  if (!realRoot) {
71
84
  realRoot = await fs.realpath(resolvedCwd);
72
85
  realRoots.set(resolvedCwd, realRoot);
73
86
  }
87
+
74
88
  let probe = fresh ? undefined : realNearest.get(target);
89
+
75
90
  if (!probe) {
76
91
  probe = await realpathNearest(target);
92
+
77
93
  if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
78
94
  realNearest.set(target, probe);
79
95
  }
96
+
80
97
  assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
98
+
81
99
  return target;
82
100
  }
83
101
 
@@ -86,21 +104,25 @@ export async function runCommand(argv, options = {}) {
86
104
  const cwd = options.cwd || process.cwd();
87
105
  const timeoutMs = options.timeoutMs ?? 60_000;
88
106
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
107
+
89
108
  return new Promise((resolve, reject) => {
90
109
  const child = spawn(argv[0], argv.slice(1), {
91
110
  cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
92
111
  });
112
+
93
113
  let stdout = "";
94
114
  let stderr = "";
95
115
  let settled = false;
96
116
  let outputTruncated = false;
97
117
  let terminationError;
98
118
  let escalation;
119
+
99
120
  const cleanup = () => {
100
121
  clearTimeout(timer);
101
122
  clearTimeout(escalation);
102
123
  options.signal?.removeEventListener("abort", onAbort);
103
124
  };
125
+
104
126
  const fail = error => {
105
127
  if (settled) return;
106
128
  settled = true;
@@ -110,12 +132,16 @@ export async function runCommand(argv, options = {}) {
110
132
  error.stderr = stderr;
111
133
  error.outputTruncated = outputTruncated;
112
134
  const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
135
+
113
136
  if (output) error.message += "\n" + output;
137
+
114
138
  if (outputTruncated) error.message += "\n[output truncated]";
115
139
  reject(error);
116
140
  };
141
+
117
142
  const signalTree = signal => {
118
143
  if (!child.pid) return;
144
+
119
145
  if (process.platform === "win32") {
120
146
  const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
121
147
  killer.on("error", () => child.kill(signal));
@@ -123,6 +149,7 @@ export async function runCommand(argv, options = {}) {
123
149
  try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
124
150
  }
125
151
  };
152
+
126
153
  const terminate = error => {
127
154
  if (settled || terminationError) return;
128
155
  terminationError = error;
@@ -130,13 +157,18 @@ export async function runCommand(argv, options = {}) {
130
157
  // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
131
158
  escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
132
159
  };
160
+
133
161
  const onAbort = () => terminate(new Error("aborted"));
134
162
  const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
163
+
135
164
  const append = (current, chunk) => {
136
165
  const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
166
+
137
167
  if (chunk.length > remaining) outputTruncated = true;
168
+
138
169
  return remaining ? current + chunk.slice(0, remaining) : current;
139
170
  };
171
+
140
172
  child.stdout.setEncoding("utf8");
141
173
  child.stderr.setEncoding("utf8");
142
174
  child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
@@ -144,6 +176,7 @@ export async function runCommand(argv, options = {}) {
144
176
  child.on("error", fail);
145
177
  child.on("close", (code, signal) => {
146
178
  if (settled) return;
179
+
147
180
  if (terminationError) {
148
181
  // A closed pipe alone says nothing about descendants. Only ESRCH proves
149
182
  // the owned POSIX group is gone; otherwise retain the escalation timer.
@@ -151,13 +184,16 @@ export async function runCommand(argv, options = {}) {
151
184
  try { process.kill(-child.pid, 0); }
152
185
  catch (error) { if (error.code === "ESRCH") fail(terminationError); }
153
186
  }
187
+
154
188
  return;
155
189
  }
190
+
156
191
  settled = true;
157
192
  cleanup();
158
193
  resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
159
194
  });
160
195
  options.signal?.addEventListener("abort", onAbort, { once: true });
196
+
161
197
  if (options.signal?.aborted) onAbort();
162
198
  });
163
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
  }