pi-supernova 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -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,7 @@ export function isTestPath(filePath) {
64
67
  return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
65
68
  }
66
69
 
67
- /** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
68
- export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
69
- if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
70
- throw new Error(`${opName} requires path`);
71
- }
72
-
73
- const trimmed = inputPath.trim();
74
-
70
+ function rejectUriPath(trimmed, opName, allowSessionRead) {
75
71
  if (/^(?:agent|artifact):\/\//i.test(trimmed)) {
76
72
  if (allowSessionRead) return trimmed;
77
73
  throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
@@ -79,19 +75,27 @@ export function assertFilesystemPath(inputPath, opName, allowSessionRead = false
79
75
 
80
76
  const uri = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/.exec(trimmed);
81
77
 
82
- if (uri) {
83
- const scheme = uri[1];
84
- const rest = uri[2];
85
- const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
78
+ if (!uri) return trimmed;
79
+ const scheme = uri[1];
80
+ const rest = uri[2];
81
+ const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
86
82
 
87
- if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
88
- throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
89
- }
83
+ if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
84
+ throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
90
85
  }
91
86
 
92
87
  return trimmed;
93
88
  }
94
89
 
90
+ /** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
91
+ export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
92
+ if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
93
+ throw new Error(`${opName} requires path`);
94
+ }
95
+
96
+ return rejectUriPath(inputPath.trim(), opName, allowSessionRead);
97
+ }
98
+
95
99
  export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
96
100
  const trimmed = assertFilesystemPath(inputPath, opName);
97
101
  const resolvedCwd = getResolvedCwd(cwd);
@@ -123,101 +127,114 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
123
127
  return target;
124
128
  }
125
129
 
130
+ function commandTimeoutMs(options) {
131
+ const requestedTimeout = Number(options.timeoutMs === undefined ? 60_000 : options.timeoutMs);
132
+
133
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
134
+
135
+ return Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
136
+ }
137
+
138
+ function spawnCommand(argv, options, cwd) {
139
+ return spawn(argv[0], argv.slice(1), {
140
+ cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
141
+ });
142
+ }
143
+
144
+ function signalProcessTree(child, signal) {
145
+ if (!child.pid) return;
146
+
147
+ if (process.platform === "win32") {
148
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
149
+ killer.on("error", () => child.kill(signal));
150
+ } else {
151
+ try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
152
+ }
153
+ }
154
+
155
+ function failCommand(state, error) {
156
+ if (state.settled) return;
157
+ state.settled = true;
158
+ state.cleanup();
159
+ // Keep bounded diagnostic output when a command times out or is cancelled.
160
+ error.stdout = state.stdout;
161
+ error.stderr = state.stderr;
162
+ error.outputTruncated = state.outputTruncated;
163
+ const output = [state.stdout, state.stderr].filter(Boolean).join("\n").trimEnd();
164
+
165
+ if (output) error.message += "\n" + output;
166
+
167
+ if (state.outputTruncated) error.message += "\n[output truncated]";
168
+ state.reject(error);
169
+ }
170
+
171
+ function terminateCommand(state, error) {
172
+ if (state.settled || state.terminationError) return;
173
+ state.terminationError = error;
174
+ signalProcessTree(state.child, "SIGTERM");
175
+ // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
176
+ state.escalation = setTimeout(() => { signalProcessTree(state.child, "SIGKILL"); failCommand(state, error); }, 150);
177
+ }
178
+
179
+ function appendCommandOutput(state, current, chunk) {
180
+ const remaining = Math.max(0, state.maxOutputChars - state.stdout.length - state.stderr.length);
181
+
182
+ if (chunk.length > remaining) state.outputTruncated = true;
183
+
184
+ return remaining ? current + chunk.slice(0, remaining) : current;
185
+ }
186
+
187
+ function onCommandClose(state, code, signal) {
188
+ if (state.settled) return;
189
+
190
+ if (state.terminationError) {
191
+ // A closed pipe alone says nothing about descendants. Only ESRCH proves
192
+ // the owned POSIX group is gone; otherwise retain the escalation timer.
193
+ if (process.platform !== "win32" && state.child.pid) {
194
+ try { process.kill(-state.child.pid, 0); }
195
+ catch (error) { if (error.code === "ESRCH") failCommand(state, state.terminationError); }
196
+ }
197
+
198
+ return;
199
+ }
200
+
201
+ state.settled = true;
202
+ state.cleanup();
203
+ state.resolve({ stdout: state.stdout, stderr: state.stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated: state.outputTruncated });
204
+ }
205
+
206
+ function attachCommandIO(state, options, argv, timeoutMs) {
207
+ const { child } = state;
208
+ const onAbort = () => terminateCommand(state, new Error("aborted"));
209
+ state.cleanup = () => {
210
+ clearTimeout(state.timer);
211
+ clearTimeout(state.escalation);
212
+ options.signal?.removeEventListener("abort", onAbort);
213
+ };
214
+ state.timer = setTimeout(() => terminateCommand(state, new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
215
+ child.stdout.setEncoding("utf8");
216
+ child.stderr.setEncoding("utf8");
217
+ child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
218
+ child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
219
+ child.on("error", error => failCommand(state, error));
220
+ child.on("close", (code, signal) => onCommandClose(state, code, signal));
221
+ options.signal?.addEventListener("abort", onAbort, { once: true });
222
+
223
+ if (options.signal?.aborted) onAbort();
224
+ }
225
+
126
226
  export async function runCommand(argv, options = {}) {
127
227
  options.signal?.throwIfAborted();
128
228
  const cwd = options.cwd || process.cwd();
129
- const timeoutMs = options.timeoutMs ?? 60_000;
229
+ const timeoutMs = commandTimeoutMs(options);
130
230
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
131
231
 
132
232
  return new Promise((resolve, reject) => {
133
- const child = spawn(argv[0], argv.slice(1), {
134
- cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
135
- });
136
-
137
- let stdout = "";
138
- let stderr = "";
139
- let settled = false;
140
- let outputTruncated = false;
141
- let terminationError;
142
- let escalation;
143
-
144
- const cleanup = () => {
145
- clearTimeout(timer);
146
- clearTimeout(escalation);
147
- options.signal?.removeEventListener("abort", onAbort);
148
- };
149
-
150
- const fail = error => {
151
- if (settled) return;
152
- settled = true;
153
- cleanup();
154
- // Keep bounded diagnostic output when a command times out or is cancelled.
155
- error.stdout = stdout;
156
- error.stderr = stderr;
157
- error.outputTruncated = outputTruncated;
158
- const output = [stdout, stderr].filter(Boolean).join("\n").trimEnd();
159
-
160
- if (output) error.message += "\n" + output;
161
-
162
- if (outputTruncated) error.message += "\n[output truncated]";
163
- reject(error);
164
- };
165
-
166
- const signalTree = signal => {
167
- if (!child.pid) return;
168
-
169
- if (process.platform === "win32") {
170
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
171
- killer.on("error", () => child.kill(signal));
172
- } else {
173
- try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
174
- }
175
- };
176
-
177
- const terminate = error => {
178
- if (settled || terminationError) return;
179
- terminationError = error;
180
- signalTree("SIGTERM");
181
- // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
182
- escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
183
- };
184
-
185
- const onAbort = () => terminate(new Error("aborted"));
186
- const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
187
-
188
- const append = (current, chunk) => {
189
- const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
190
-
191
- if (chunk.length > remaining) outputTruncated = true;
192
-
193
- return remaining ? current + chunk.slice(0, remaining) : current;
194
- };
195
-
196
- child.stdout.setEncoding("utf8");
197
- child.stderr.setEncoding("utf8");
198
- child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
199
- child.stderr.on("data", chunk => { stderr = append(stderr, chunk); });
200
- child.on("error", fail);
201
- child.on("close", (code, signal) => {
202
- if (settled) return;
203
-
204
- if (terminationError) {
205
- // A closed pipe alone says nothing about descendants. Only ESRCH proves
206
- // the owned POSIX group is gone; otherwise retain the escalation timer.
207
- if (process.platform !== "win32" && child.pid) {
208
- try { process.kill(-child.pid, 0); }
209
- catch (error) { if (error.code === "ESRCH") fail(terminationError); }
210
- }
211
-
212
- return;
213
- }
214
-
215
- settled = true;
216
- cleanup();
217
- resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
218
- });
219
- options.signal?.addEventListener("abort", onAbort, { once: true });
220
-
221
- if (options.signal?.aborted) onAbort();
233
+ const child = spawnCommand(argv, options, cwd);
234
+ attachCommandIO({
235
+ child, resolve, reject, stdout: "", stderr: "", settled: false,
236
+ outputTruncated: false, terminationError: undefined, escalation: undefined,
237
+ maxOutputChars, timer: undefined, cleanup() {},
238
+ }, options, argv, timeoutMs);
222
239
  });
223
240
  }
@@ -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) {
@@ -36,165 +36,269 @@ function extractRawString(raw) {
36
36
  return json(raw);
37
37
  }
38
38
 
39
+ function fitString(input, limit) {
40
+ let low = 0;
41
+ let high = Math.min(input.length, limit);
42
+
43
+ while (low < high) {
44
+ const mid = Math.ceil((low + high) / 2);
45
+
46
+ if (json(truncateChars(input, mid).text).length <= limit) low = mid;
47
+ else high = mid - 1;
48
+ }
49
+
50
+ return truncateChars(input, low).text;
51
+ }
52
+
53
+ function assignBounded(out, key, bounded) {
54
+ if (Array.isArray(out)) out.push(bounded);
55
+ else Object.defineProperty(out, key, { value: bounded, enumerable: true, configurable: true });
56
+ }
57
+
58
+ function rollbackBounded(out, key) {
59
+ if (Array.isArray(out)) out.pop();
60
+ else delete out[key];
61
+ }
62
+
63
+ function fitContainer(input, limit) {
64
+ const out = Array.isArray(input) ? [] : { truncated: true };
65
+ const entries = Object.entries(input);
66
+
67
+ if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
68
+
69
+ for (const [key, child] of entries) {
70
+ const used = json(out).length;
71
+ const overhead = Array.isArray(out) ? 1 : json(key).length + 2;
72
+ const available = limit - used - overhead;
73
+
74
+ if (available < 4) break;
75
+ assignBounded(out, key, fit(child, available));
76
+
77
+ if (json(out).length > limit) rollbackBounded(out, key);
78
+ }
79
+
80
+ return out;
81
+ }
82
+
83
+ function fit(input, limit) {
84
+ const serialized = json(input);
85
+
86
+ if (serialized.length <= limit) return input;
87
+
88
+ if (isString(input)) return fitString(input, limit);
89
+
90
+ if (!isObject(input) && !Array.isArray(input)) return null;
91
+
92
+ return fitContainer(input, limit);
93
+ }
94
+
39
95
  /** Bound JSON before serialization; preserve small scalar fields such as exitCode. */
40
96
  function summarizeDetails(value, budget = 2000) {
41
97
  const encoded = json(value);
42
98
 
43
99
  if (encoded.length <= budget) return encoded;
44
- const snapshot = JSON.parse(encoded);
45
100
 
46
- const fit = (input, limit) => {
47
- const serialized = json(input);
101
+ return json(fit(JSON.parse(encoded), budget));
102
+ }
103
+
104
+ function spill(fullText, config) {
105
+ try {
106
+ if (!isString(config.spillDir) || !config.spillDir) return undefined;
107
+ fs.mkdirSync(config.spillDir, { recursive: true, mode: 0o700 });
108
+ const file = path.join(config.spillDir, Date.now() + "-" + randomUUID().slice(0, 8) + ".txt");
109
+ fs.writeFileSync(file, fullText, { encoding: "utf8", mode: 0o600, flag: "wx" });
110
+
111
+ return file;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
48
116
 
49
- if (serialized.length <= limit) return input;
117
+ function batchFromDetails(details) {
118
+ return details?.batch === true && Array.isArray(details.items) ? details.items : undefined;
119
+ }
50
120
 
51
- if (isString(input)) {
52
- let low = 0;
53
- let high = Math.min(input.length, limit);
121
+ function hostImage(raw) {
122
+ return raw?.content?.find(part => part?.type === "image");
123
+ }
54
124
 
55
- while (low < high) {
56
- const mid = Math.ceil((low + high) / 2);
125
+ function directoryEntriesIfFit(image, details, maxChars) {
126
+ return image === undefined && details?.directory === true && Array.isArray(details.entries) && json(details.entries).length <= maxChars
127
+ ? details.entries
128
+ : undefined;
129
+ }
57
130
 
58
- if (json(truncateChars(input, mid).text).length <= limit) low = mid;
59
- else high = mid - 1;
60
- }
131
+ function hostTruncated(capped, details) {
132
+ return capped.truncated || details?.outputTruncated === true;
133
+ }
61
134
 
62
- return truncateChars(input, low).text;
63
- }
135
+ function hostResultValue(image, directoryEntries, capped) {
136
+ return image ?? directoryEntries ?? capped.text;
137
+ }
64
138
 
65
- if (!isObject(input) && !Array.isArray(input)) return null;
66
- const out = Array.isArray(input) ? [] : { truncated: true };
67
- const entries = Object.entries(input);
139
+ function attachDetails(result, details, batch) {
140
+ if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
141
+ }
68
142
 
69
- if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
143
+ function boundNonStringItem(item, share) {
144
+ const encoded = json(item);
70
145
 
71
- for (const [key, child] of entries) {
72
- const used = json(out).length;
73
- const overhead = Array.isArray(out) ? 1 : json(key).length + 2;
74
- const available = limit - used - overhead;
146
+ if (encoded.length <= share) return { value: item, used: encoded.length, truncated: false };
147
+ const bounded = truncateChars(encoded, share, "host-result");
75
148
 
76
- if (available < 4) break;
77
- const bounded = fit(child, available);
149
+ return { value: bounded.text, used: bounded.text.length, truncated: bounded.truncated };
150
+ }
78
151
 
79
- if (Array.isArray(out)) out.push(bounded);
80
- else Object.defineProperty(out, key, { value: bounded, enumerable: true, configurable: true });
152
+ function boundBatchItem(item, share) {
153
+ if (item?.type === "image") return { value: item, used: 0, truncated: false };
81
154
 
82
- if (json(out).length > limit) {
83
- if (Array.isArray(out)) out.pop();
84
- else delete out[key];
85
- }
86
- }
155
+ if (!isString(item)) return boundNonStringItem(item, share);
156
+ const bounded = truncateChars(item, share, "host-result");
87
157
 
88
- return out;
89
- };
158
+ return { value: bounded.text, used: bounded.text.length, truncated: bounded.truncated };
159
+ }
90
160
 
91
- return json(fit(snapshot, budget));
161
+ function boundItemError(error, maxChars, batchLength) {
162
+ return error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batchLength)), "error").text;
92
163
  }
93
164
 
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" });
165
+ function packageBatchItems(batch, details, maxChars) {
166
+ const itemErrors = (details.itemErrors ?? []).map(error => boundItemError(error, maxChars, batch.length));
167
+ let remaining = maxChars;
168
+ let truncated = false;
169
+ const items = batch.map((item, index) => {
170
+ const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
171
+ const bounded = boundBatchItem(item, share);
172
+ remaining -= bounded.used;
173
+ truncated ||= bounded.truncated;
174
+
175
+ return bounded.value;
176
+ });
99
177
 
100
- return file;
178
+ return { items, itemErrors, truncated };
179
+ }
180
+
181
+ function attachBatch(result, batch, details, maxChars) {
182
+ if (!batch) return false;
183
+ const packed = packageBatchItems(batch, details, maxChars);
184
+ result.itemErrors = packed.itemErrors;
185
+ result.items = packed.items;
186
+
187
+ return packed.truncated;
188
+ }
189
+
190
+ function originalBatchChars(batch) {
191
+ return batch.reduce((sum, item) => sum + (isString(item) ? item.length : json(item).length), 0);
192
+ }
193
+
194
+ function spillPayload(batch, text) {
195
+ return batch ? batch.map(item => isString(item) ? item : json(item)).join("\n---\n") : text;
196
+ }
197
+
198
+ function applySpillFooter(result, text, pointer, maxChars, capped) {
199
+ const footer = "\n[full output spilled to " + pointer + "]";
200
+ result.value = footer.length <= maxChars
201
+ ? truncateChars(text, maxChars - footer.length, "host-result").text + footer
202
+ : capped.text;
203
+ }
204
+
205
+ function attachSpill(result, batch, text, config, maxChars, capped) {
206
+ if (!config.spillDir) return;
207
+ const pointer = spill(spillPayload(batch, text), config);
208
+
209
+ if (!pointer) return;
210
+ result.spill = pointer;
211
+
212
+ if (!batch) applySpillFooter(result, text, pointer, maxChars, capped);
213
+ }
214
+
215
+ function attachTruncation(result, truncated, batch, text, config, maxChars, capped) {
216
+ result.truncated = truncated;
217
+
218
+ if (!truncated) return;
219
+ result.originalChars = batch ? originalBatchChars(batch) : text.length;
220
+ attachSpill(result, batch, text, config, maxChars, capped);
101
221
  }
102
222
 
103
223
  export function packageHostResult(raw, config) {
104
224
  const maxChars = config.maxCallResultChars ?? 65536;
105
225
  const details = detailsOf(raw);
106
- const batch = details?.batch === true && Array.isArray(details.items) ? details.items : undefined;
226
+ const batch = batchFromDetails(details);
107
227
  const text = batch ? "" : extractRawString(raw);
108
228
  const capped = truncateChars(text, maxChars, "host-result");
109
- let truncated = capped.truncated || details?.outputTruncated === true;
110
- const image = raw?.content?.find(part => part?.type === "image");
111
- const result = { ok: !hostResultFailed(raw), value: image ?? capped.text, truncated };
229
+ let truncated = hostTruncated(capped, details);
230
+ const image = hostImage(raw);
231
+ const directoryEntries = directoryEntriesIfFit(image, details, maxChars);
232
+ const result = { ok: !hostResultFailed(raw), value: hostResultValue(image, directoryEntries, capped), truncated };
233
+ attachDetails(result, details, batch);
234
+ truncated ||= attachBatch(result, batch, details, maxChars);
235
+ attachTruncation(result, truncated, batch, text, config, maxChars, capped);
112
236
 
113
- if (details !== undefined) result.details = summarizeDetails(batch ? { ...details, items: undefined } : details);
114
-
115
- if (batch) {
116
- result.itemErrors = (details.itemErrors ?? []).map(error => error == null ? null : truncateChars(String(error), Math.max(1, Math.floor(maxChars / batch.length)), "error").text);
117
- let remaining = maxChars;
118
- result.items = batch.map((item, index) => {
119
- if (item?.type === "image") return item;
120
- const bounded = truncateChars(item, details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index)), "host-result");
121
- remaining -= bounded.text.length;
122
- truncated ||= bounded.truncated;
123
-
124
- return bounded.text;
125
- });
126
- }
127
-
128
- result.truncated = truncated;
129
-
130
- if (truncated) {
131
- result.originalChars = batch ? batch.reduce((sum, item) => sum + String(item).length, 0) : text.length;
237
+ return result;
238
+ }
132
239
 
133
- if (config.spillDir) {
134
- const pointer = spill(batch ? batch.join("\n---\n") : text, config);
240
+ function collectImage(input, acc) {
241
+ if (!(input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/"))) return null;
242
+ const size = Buffer.byteLength(input.data, "base64");
135
243
 
136
- if (pointer) {
137
- result.spill = pointer;
244
+ if (acc.images.length >= 16 || acc.imageBytes + size > 20 * 1024 * 1024) {
245
+ acc.imageOverflow = true;
138
246
 
139
- if (!batch) {
140
- const footer = "\n[full output spilled to " + pointer + "]";
141
- result.value = footer.length <= maxChars
142
- ? truncateChars(text, maxChars - footer.length, "host-result").text + footer
143
- : capped.text;
144
- }
145
- }
146
- }
247
+ return "[image omitted: exceeds 16 attachments or 20 MiB]";
147
248
  }
148
249
 
149
- return result;
250
+ acc.imageBytes += size;
251
+ acc.images.push({ type: "image", data: input.data, mimeType: input.mimeType });
252
+
253
+ return `[image ${acc.images.length}: ${input.mimeType}]`;
150
254
  }
151
255
 
152
- export function packageFinalReturn(value, logs, config) {
153
- const images = [];
154
- let imageBytes = 0;
155
- let imageOverflow = false;
256
+ function collectImages(input, acc) {
257
+ const replaced = collectImage(input, acc);
156
258
 
157
- const collect = input => {
158
- if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
159
- const size = Buffer.byteLength(input.data, "base64");
259
+ if (replaced !== null) return replaced;
160
260
 
161
- if (images.length >= 16 || imageBytes + size > 20 * 1024 * 1024) {
162
- imageOverflow = true;
261
+ if (Array.isArray(input)) return input.map(child => collectImages(child, acc));
163
262
 
164
- return "[image omitted: exceeds 16 attachments or 20 MiB]";
165
- }
263
+ if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collectImages(child, acc)]));
166
264
 
167
- imageBytes += size;
168
- images.push({ type: "image", data: input.data, mimeType: input.mimeType });
265
+ return input;
266
+ }
169
267
 
170
- return `[image ${images.length}: ${input.mimeType}]`;
171
- }
268
+ function serializeReturn(value, formatted, maxReturn, imageOverflow) {
269
+ if (formatted.length <= maxReturn) return { text: formatted, truncated: imageOverflow };
172
270
 
173
- if (Array.isArray(input)) return input.map(collect);
271
+ if (Array.isArray(value) && value.length && value.every(isString)) return { text: formatBoundedStringArray(value, maxReturn), truncated: true };
174
272
 
175
- if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, collect(child)]));
273
+ return { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
274
+ }
176
275
 
177
- return input;
178
- };
276
+ function clipLogLine(line, maxLogLineChars) {
277
+ const result = truncateChars(line, maxLogLineChars, "log");
179
278
 
180
- value = collect(value);
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 };
279
+ return { text: result.text, truncated: result.truncated };
280
+ }
281
+
282
+ function clipLogs(logs, config) {
188
283
  const maxLines = config.maxLogLines ?? 100;
189
284
  let logTruncated = logs.length > maxLines;
190
-
191
285
  const clipped = logs.slice(0, maxLines).map(line => {
192
- const result = truncateChars(line, config.maxLogLineChars ?? 4096, "log");
286
+ const result = clipLogLine(line, config.maxLogLineChars ?? 4096);
193
287
  logTruncated ||= result.truncated;
194
288
 
195
289
  return result.text;
196
290
  });
197
291
 
292
+ return { logs: clipped, logTruncated };
293
+ }
294
+
295
+ export function packageFinalReturn(value, logs, config) {
296
+ const acc = { images: [], imageBytes: 0, imageOverflow: false };
297
+ value = collectImages(value, acc);
298
+ const maxReturn = config.maxReturnChars ?? 32000;
299
+ const serialized = serializeReturn(value, formatReturn(value), maxReturn, acc.imageOverflow);
300
+ const clipped = clipLogs(logs, config);
301
+
198
302
  return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
199
- returnTruncated: serialized.truncated, logs: clipped, logTruncated, images };
303
+ returnTruncated: serialized.truncated, logs: clipped.logs, logTruncated: clipped.logTruncated, images: acc.images };
200
304
  }