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