pi-supernova 0.8.2 → 0.9.1
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 +188 -51
- package/docs/CHANGELOG.md +86 -1
- package/docs/TOKEN_COSTS.md +38 -0
- package/index.js +10 -175
- package/package.json +2 -1
- package/src/adapters/bash.js +14 -30
- package/src/adapters/errors.js +1 -9
- package/src/adapters/read-focus.js +98 -0
- package/src/adapters/read-image.js +51 -0
- package/src/adapters/read-json.js +42 -0
- package/src/adapters/read-text.js +71 -0
- package/src/adapters/read.js +66 -635
- package/src/bridge/catalog.js +3 -2
- package/src/bridge/host-bridge.js +35 -167
- package/src/bridge/tool-registry.js +104 -0
- package/src/bridge/trace.js +41 -0
- package/src/context/evidence-graph.js +249 -0
- package/src/context/evidence-rank.js +153 -0
- package/src/context/evidence.js +10 -424
- package/src/context/fuzzy.js +116 -43
- package/src/context/query.js +80 -0
- package/src/context/repo-index.js +23 -166
- package/src/context/search-files.js +19 -0
- package/src/context/search.js +2 -24
- package/src/context/snap-search.js +203 -0
- package/src/context/snap.js +5 -266
- package/src/context/source-entry.js +112 -0
- package/src/contract/bash.js +6 -1
- package/src/contract/program.js +36 -0
- package/src/contract/read.js +8 -53
- package/src/fs/check.js +1 -1
- package/src/fs/commit.js +161 -0
- package/src/fs/diff.js +11 -15
- package/src/fs/directory.js +79 -0
- package/src/fs/file-io.js +100 -0
- package/src/fs/glob.js +54 -0
- package/src/fs/json-size.js +54 -0
- package/src/fs/lines.js +117 -0
- package/src/fs/read-window.js +74 -0
- package/src/fs/session-resource.js +50 -0
- package/src/fs/text-ops.js +7 -227
- package/src/fs/vfs.js +5 -239
- package/src/fs/workspace.js +2 -1
- package/src/output/bottleneck.js +13 -67
- package/src/output/final.js +114 -0
- package/src/output/format.js +94 -5
- package/src/output/outcome.js +91 -0
- package/src/runtime/batch-input.js +68 -0
- package/src/runtime/guest-api.js +281 -0
- package/src/runtime/guest-worker.js +62 -333
- package/src/runtime/parallel.js +41 -39
- package/src/runtime/program-batch.js +21 -75
- package/src/runtime/program-file.js +3 -11
- package/src/runtime/program.js +141 -0
- package/src/runtime/reference.js +6 -5
- package/src/runtime/runtime.js +77 -253
- package/src/runtime/worker-pool.js +91 -0
- package/src/shared/decode.js +22 -8
- package/src/shared/image-worker.js +30 -0
- package/src/shared/image.js +78 -0
- package/src/shared/png.js +57 -0
- package/src/shared/result.js +77 -0
- package/src/shared/syntax-context.js +61 -3
- package/src/ui/host-render.js +104 -0
- package/src/ui/progress.js +51 -0
- package/src/ui/render.js +21 -421
- package/src/ui/trace.js +277 -0
package/src/fs/vfs.js
CHANGED
|
@@ -1,248 +1,14 @@
|
|
|
1
|
+
import {textSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
|
|
2
|
+
import {resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
|
|
3
|
+
export {resolveCommitTarget} from './commit.js';
|
|
1
4
|
import * as fs from "node:fs/promises";
|
|
2
5
|
import * as path from "node:path";
|
|
3
6
|
import { isString } from "../shared/decode.js";
|
|
4
7
|
import { decodeUtf8Strict } from "../shared/utf8.js";
|
|
5
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
6
8
|
|
|
7
9
|
// Serialize validation + replacement across Supernova transactions in this host.
|
|
8
10
|
let commitTail = Promise.resolve();
|
|
9
11
|
|
|
10
|
-
function textSignature(text) {
|
|
11
|
-
return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function sameFileVersion(a, b) {
|
|
15
|
-
return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function fileSignature(target, signal, observed) {
|
|
19
|
-
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
const actual = await file.stat();
|
|
23
|
-
|
|
24
|
-
if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
|
|
25
|
-
if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
|
|
26
|
-
const hash = createHash("sha256");
|
|
27
|
-
|
|
28
|
-
for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
|
|
29
|
-
const after = await file.stat();
|
|
30
|
-
|
|
31
|
-
if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
|
|
32
|
-
|
|
33
|
-
return { size: actual.size, sha256: hash.digest("hex") };
|
|
34
|
-
} finally {
|
|
35
|
-
await file.close();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
|
|
40
|
-
// ancestor so two symlink spellings still share one commit destination.
|
|
41
|
-
async function canonicalNewPath(target) {
|
|
42
|
-
let ancestor = path.dirname(target);
|
|
43
|
-
|
|
44
|
-
for (;;) {
|
|
45
|
-
try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
|
|
46
|
-
catch (error) {
|
|
47
|
-
if (error.code !== "ENOENT") throw error;
|
|
48
|
-
const parent = path.dirname(ancestor);
|
|
49
|
-
|
|
50
|
-
if (parent === ancestor) throw error;
|
|
51
|
-
ancestor = parent;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function sameSignature(a, b) {
|
|
57
|
-
return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function tooLargeRead(label, maxBytes) {
|
|
61
|
-
return new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function overlayOrThrow(overlay, maxBytes, label) {
|
|
65
|
-
if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw tooLargeRead(label, maxBytes);
|
|
66
|
-
|
|
67
|
-
return overlay;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function assertReadableFile(stat, target) {
|
|
71
|
-
// Callers are reads, writes, edits and patch application: name the path, not the caller.
|
|
72
|
-
if (stat.isDirectory()) throw new Error("path is a directory, not a file: " + target);
|
|
73
|
-
|
|
74
|
-
if (!stat.isFile()) throw new Error("path is not a regular file: " + target);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function readLimitedBytes(file, stat, maxBytes, label, signal) {
|
|
78
|
-
if (stat.size > maxBytes) throw tooLargeRead(label, maxBytes);
|
|
79
|
-
const chunks = [];
|
|
80
|
-
let size = 0;
|
|
81
|
-
|
|
82
|
-
for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal })) {
|
|
83
|
-
size += chunk.length;
|
|
84
|
-
|
|
85
|
-
if (size > maxBytes) throw tooLargeRead(label, maxBytes);
|
|
86
|
-
chunks.push(chunk);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return Buffer.concat(chunks);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function remapReadError(err, target) {
|
|
93
|
-
if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
|
|
94
|
-
|
|
95
|
-
if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
|
|
96
|
-
if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
|
|
97
|
-
|
|
98
|
-
if (err.code === "ENOENT") {
|
|
99
|
-
const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)');
|
|
100
|
-
missing.code = "ENOENT";
|
|
101
|
-
throw missing;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function resolveExistingFile(logicalPath) {
|
|
108
|
-
const target = await fs.realpath(logicalPath);
|
|
109
|
-
const stat = await fs.stat(target);
|
|
110
|
-
|
|
111
|
-
if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
|
|
112
|
-
|
|
113
|
-
return { target, stat };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function resolveCommitTarget(logicalPath) {
|
|
117
|
-
try {
|
|
118
|
-
return await resolveExistingFile(logicalPath);
|
|
119
|
-
} catch (err) {
|
|
120
|
-
if (err.code !== "ENOENT") throw err;
|
|
121
|
-
|
|
122
|
-
return { target: await canonicalNewPath(logicalPath), stat: undefined };
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function collectMissingAncestors(parent) {
|
|
127
|
-
const missing = [];
|
|
128
|
-
let probe = parent;
|
|
129
|
-
|
|
130
|
-
for (;;) {
|
|
131
|
-
try { await fs.stat(probe); break; } catch (err) {
|
|
132
|
-
if (err.code !== "ENOENT") throw err;
|
|
133
|
-
missing.push(probe);
|
|
134
|
-
probe = path.dirname(probe);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return missing;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function writeTemporary(entry, content, stat) {
|
|
142
|
-
try {
|
|
143
|
-
await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
|
|
144
|
-
} catch (error) {
|
|
145
|
-
// Never leak the temporary name: name the destination and the real cause.
|
|
146
|
-
if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
|
|
147
|
-
if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
|
|
148
|
-
if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
|
|
149
|
-
|
|
150
|
-
throw error;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function stageReplacement(entry, content, stat, target) {
|
|
157
|
-
const replacement = writeTemporary(entry, content, stat);
|
|
158
|
-
// These touch separate staging files. Settle both before cleanup, even
|
|
159
|
-
// on failure: Promise.all could leave a late backup after rollback.
|
|
160
|
-
const staging = [replacement];
|
|
161
|
-
|
|
162
|
-
if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
|
|
163
|
-
const outcomes = await Promise.allSettled(staging);
|
|
164
|
-
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
165
|
-
|
|
166
|
-
if (failure) throw failure.reason;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function makeStageEntry(logicalPath, target, content, parent, stat) {
|
|
170
|
-
const token = ".supernova-" + randomUUID();
|
|
171
|
-
|
|
172
|
-
return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function recoverReplaced(staged) {
|
|
176
|
-
const recoveryErrors = [];
|
|
177
|
-
|
|
178
|
-
for (const entry of staged.toReversed()) {
|
|
179
|
-
if (!entry.replaced) continue;
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
if (entry.existed) await fs.rename(entry.backup, entry.target);
|
|
183
|
-
else await fs.unlink(entry.target);
|
|
184
|
-
} catch (err) {
|
|
185
|
-
// Keep the backup if recovery fails; never delete the remaining original.
|
|
186
|
-
entry.keepBackup = true;
|
|
187
|
-
recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return recoveryErrors;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function cleanupStaged(staged, failed, createdDirs) {
|
|
195
|
-
for (const entry of staged) {
|
|
196
|
-
// A successful rename consumed the temporary path. These are known
|
|
197
|
-
// files, so unlink avoids rm's extra type probe; missing files stay benign.
|
|
198
|
-
if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
|
|
199
|
-
|
|
200
|
-
if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function assertExpectedSignature(vfs, logicalPath, target, stat) {
|
|
207
|
-
if (!vfs.expected.has(logicalPath)) return;
|
|
208
|
-
const current = stat ? await fileSignature(target, vfs.signal) : null;
|
|
209
|
-
|
|
210
|
-
if (!sameSignature(current, vfs.expected.get(logicalPath))) {
|
|
211
|
-
throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async function installStaged(vfs, staged) {
|
|
216
|
-
for (const entry of staged) {
|
|
217
|
-
vfs.signal?.throwIfAborted();
|
|
218
|
-
await fs.rename(entry.temporary, entry.target);
|
|
219
|
-
entry.replaced = true;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
for (const entry of staged) {
|
|
223
|
-
vfs.expected.set(entry.logicalPath, textSignature(entry.content));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Canonical commit destinations must not rewrite established event paths
|
|
227
|
-
// for newly created files, whose callers supplied a logical cwd spelling.
|
|
228
|
-
if (staged.length) vfs.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async function failCommit(vfs, staged, error) {
|
|
232
|
-
const recoveryErrors = await recoverReplaced(staged);
|
|
233
|
-
// Keep CAS baselines: a failed commit must not forgive conflicts on files it
|
|
234
|
-
// never touched. If recovery left disk diverging from a baseline, the next
|
|
235
|
-
// write to that path fails loudly and forces a re-read instead of silently
|
|
236
|
-
// re-capturing unknown bytes as the new truth.
|
|
237
|
-
|
|
238
|
-
if (recoveryErrors.length) vfs.mutations.recoveryFailed = true;
|
|
239
|
-
|
|
240
|
-
if (recoveryErrors.length) vfs.onNewFile?.(null);
|
|
241
|
-
|
|
242
|
-
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
12
|
export class CausalVfs {
|
|
247
13
|
constructor(onNewFile, validateWrite) {
|
|
248
14
|
this.validateWrite = validateWrite;
|
|
@@ -275,7 +41,7 @@ export class CausalVfs {
|
|
|
275
41
|
async read(target, { preserveRead = false, maxBytes, label = "read input", strict = true } = {}) {
|
|
276
42
|
const overlay = this.getOverlay(target);
|
|
277
43
|
|
|
278
|
-
if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
|
|
44
|
+
if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label, target);
|
|
279
45
|
|
|
280
46
|
// External editors and captured tools can change a file between any two reads.
|
|
281
47
|
// Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
|
|
@@ -288,7 +54,7 @@ export class CausalVfs {
|
|
|
288
54
|
assertReadableFile(stat, target);
|
|
289
55
|
bytes = maxBytes === undefined
|
|
290
56
|
? await file.readFile({ signal: this.signal })
|
|
291
|
-
: await readLimitedBytes(file, stat, maxBytes, label, this.signal);
|
|
57
|
+
: await readLimitedBytes(file, stat, maxBytes, label, this.signal, () => tooLargeRead(label,maxBytes,target));
|
|
292
58
|
if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
|
|
293
59
|
} finally { await file.close(); }
|
|
294
60
|
|
package/src/fs/workspace.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {remapReadError} from "./file-io.js";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
@@ -44,7 +45,7 @@ async function realpathNearest(target) {
|
|
|
44
45
|
try {
|
|
45
46
|
return await fs.realpath(probe);
|
|
46
47
|
} catch (err) {
|
|
47
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR")
|
|
48
|
+
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") remapReadError(err, target);
|
|
48
49
|
const parent = path.dirname(probe);
|
|
49
50
|
|
|
50
51
|
if (parent === probe) throw err;
|
package/src/output/bottleneck.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import {READ_VALUE} from "../shared/result.js";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { isString, isObject
|
|
5
|
-
import { truncateChars
|
|
5
|
+
import { isString, isObject } from "../shared/decode.js";
|
|
6
|
+
import { truncateChars } from "./format.js";
|
|
6
7
|
|
|
7
8
|
function json(value) {
|
|
8
9
|
try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
|
|
@@ -221,8 +222,16 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
export function packageHostResult(raw, config) {
|
|
224
|
-
const maxChars = config.maxCallResultChars ?? 65536;
|
|
225
225
|
const details = detailsOf(raw);
|
|
226
|
+
if (raw && Object.hasOwn(raw, READ_VALUE)) {
|
|
227
|
+
const batch = batchFromDetails(details);
|
|
228
|
+
const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
|
|
229
|
+
attachDetails(result, details, batch);
|
|
230
|
+
if (batch) { result.items = batch; result.itemErrors = details.itemErrors ?? []; }
|
|
231
|
+
if (details?.streamed) result.streamed = true;
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
const maxChars = config.maxCallResultChars ?? 65536;
|
|
226
235
|
const batch = batchFromDetails(details);
|
|
227
236
|
const text = batch ? "" : extractRawString(raw);
|
|
228
237
|
const capped = truncateChars(text, maxChars, "host-result");
|
|
@@ -237,67 +246,4 @@ export function packageHostResult(raw, config) {
|
|
|
237
246
|
return result;
|
|
238
247
|
}
|
|
239
248
|
|
|
240
|
-
|
|
241
|
-
if (!(input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/"))) return null;
|
|
242
|
-
assertModelImageMime(input.mimeType);
|
|
243
|
-
const size = Buffer.byteLength(input.data, "base64");
|
|
244
|
-
|
|
245
|
-
acc.imageCount += 1;
|
|
246
|
-
acc.imageBytes += size;
|
|
247
|
-
if (acc.imageCount > 16 || acc.imageBytes > 20 * 1024 * 1024) {
|
|
248
|
-
acc.imageOverflow = true;
|
|
249
|
-
return "[image over budget]";
|
|
250
|
-
}
|
|
251
|
-
acc.images.push({ type: "image", data: input.data, mimeType: input.mimeType });
|
|
252
|
-
|
|
253
|
-
return `[image ${acc.images.length}: ${input.mimeType}]`;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function collectImages(input, acc) {
|
|
257
|
-
const replaced = collectImage(input, acc);
|
|
258
|
-
|
|
259
|
-
if (replaced !== null) return replaced;
|
|
260
|
-
|
|
261
|
-
return mapChangedChildren(input, collectImages, acc);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function serializeReturn(value, formatted, maxReturn, imageOverflow) {
|
|
265
|
-
if (formatted.length <= maxReturn) return { text: formatted, truncated: imageOverflow };
|
|
266
|
-
|
|
267
|
-
if (Array.isArray(value) && value.length && value.every(isString)) return { text: formatBoundedStringArray(value, maxReturn), truncated: true };
|
|
268
|
-
|
|
269
|
-
return { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function clipLogLine(line, maxLogLineChars) {
|
|
273
|
-
const result = truncateChars(line, maxLogLineChars, "log");
|
|
274
|
-
|
|
275
|
-
return { text: result.text, truncated: result.truncated };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function clipLogs(logs, config) {
|
|
279
|
-
const maxLines = config.maxLogLines ?? 100;
|
|
280
|
-
let logTruncated = logs.length > maxLines;
|
|
281
|
-
const clipped = logs.slice(0, maxLines).map(line => {
|
|
282
|
-
const result = clipLogLine(line, config.maxLogLineChars ?? 4096);
|
|
283
|
-
logTruncated ||= result.truncated;
|
|
284
|
-
|
|
285
|
-
return result.text;
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
return { logs: clipped, logTruncated };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export function packageFinalReturn(value, logs, config) {
|
|
292
|
-
const acc = { images: [], imageCount: 0, imageBytes: 0, imageOverflow: false };
|
|
293
|
-
value = collectImages(value, acc);
|
|
294
|
-
if (acc.imageOverflow) {
|
|
295
|
-
throw new Error(`image attachment budget exceeded: ${acc.imageCount} images / ${acc.imageBytes} bytes; limit is 16 images / 20971520 bytes (20 MiB). No images returned; return fewer or smaller images per program`);
|
|
296
|
-
}
|
|
297
|
-
const maxReturn = config.maxReturnChars ?? 32000;
|
|
298
|
-
const serialized = serializeReturn(value, formatReturn(value), maxReturn, acc.imageOverflow);
|
|
299
|
-
const clipped = clipLogs(logs, config);
|
|
300
|
-
|
|
301
|
-
return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
|
|
302
|
-
returnTruncated: serialized.truncated, logs: clipped.logs, logTruncated: clipped.logTruncated, images: acc.images };
|
|
303
|
-
}
|
|
249
|
+
export {packageFinalReturn} from "./final.js";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {isString,mapChangedChildren,assertModelImageMime,decodeImageData} from "../shared/decode.js";
|
|
2
|
+
import {assertPng} from "../shared/png.js";
|
|
3
|
+
import {truncateChars,formatReturn,formatBoundedStringArray,formatBoundedValue,isStringArray} from "./format.js";
|
|
4
|
+
|
|
5
|
+
function collectImage(input, acc) {
|
|
6
|
+
if (!(input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/"))) return null;
|
|
7
|
+
assertModelImageMime(input.mimeType);
|
|
8
|
+
const size = Buffer.byteLength(input.data, "base64");
|
|
9
|
+
|
|
10
|
+
acc.imageCount += 1;
|
|
11
|
+
acc.imageBytes += size;
|
|
12
|
+
if (acc.imageCount > 16 || acc.imageBytes > 20 * 1024 * 1024) {
|
|
13
|
+
acc.imageOverflow = true;
|
|
14
|
+
return "[image over budget]";
|
|
15
|
+
}
|
|
16
|
+
const bytes = decodeImageData(input.data);
|
|
17
|
+
if (input.mimeType === "image/png") assertPng(bytes);
|
|
18
|
+
acc.images.push({ type: "image", data: input.data, mimeType: input.mimeType });
|
|
19
|
+
|
|
20
|
+
return `[image ${acc.images.length}: ${input.mimeType}]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function displayWeight(value,key) {
|
|
24
|
+
// Count even omitted undefined fields: typed result metadata crosses RPC too.
|
|
25
|
+
const field = isString(key) ? key.length+1 : 0;
|
|
26
|
+
return field + (isString(value) ? value.length : 1) + (Array.isArray(value) ? value.length : 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectImages(input, acc, key) {
|
|
30
|
+
const replaced = collectImage(input, acc);
|
|
31
|
+
acc.displayUnits += displayWeight(replaced ?? input,key);
|
|
32
|
+
if (replaced !== null) return replaced;
|
|
33
|
+
|
|
34
|
+
return mapChangedChildren(input, collectImages, acc);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serializeReturn(value, formatted, maxReturn, imageOverflow) {
|
|
38
|
+
if (formatted.length <= maxReturn) return { text: formatted, truncated: imageOverflow };
|
|
39
|
+
|
|
40
|
+
if (isStringArray(value)) return { text: formatBoundedStringArray(value, maxReturn), truncated: true };
|
|
41
|
+
|
|
42
|
+
return { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clipLogLine(line, maxLogLineChars) {
|
|
46
|
+
const result = truncateChars(line, maxLogLineChars, "log");
|
|
47
|
+
|
|
48
|
+
return { text: result.text, truncated: result.truncated };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clipLogs(logs, config) {
|
|
52
|
+
const maxLines = config.maxLogLines ?? 100;
|
|
53
|
+
let logTruncated = logs.length > maxLines;
|
|
54
|
+
const clipped = logs.slice(0, maxLines).map(line => {
|
|
55
|
+
const result = clipLogLine(line, config.maxLogLineChars ?? 4096);
|
|
56
|
+
logTruncated ||= result.truncated;
|
|
57
|
+
|
|
58
|
+
return result.text;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { logs: clipped, logTruncated };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isSourceView(value) {
|
|
65
|
+
return value?.status === "found" && isString(value.path) && isString(value.text) && Array.isArray(value.lines);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sourcePreview(value, maxReturn) {
|
|
69
|
+
if (!isSourceView(value)) return null;
|
|
70
|
+
if (value.text.length < maxReturn / 2) return null;
|
|
71
|
+
if (value.text.length <= maxReturn && formatReturn(value).length <= maxReturn-256) return null;
|
|
72
|
+
const base = {...value,text:"",complete:false,nextOffset:value.lines[0]+1};
|
|
73
|
+
let room = maxReturn - formatReturn(base).length - 512;
|
|
74
|
+
while (room > 0) {
|
|
75
|
+
const end = value.text.lastIndexOf("\n",room-1) + 1;
|
|
76
|
+
if (!end || end >= value.text.length) return null;
|
|
77
|
+
const text = value.text.slice(0,end);
|
|
78
|
+
const lines = [value.lines[0],value.lines[0]+text.match(/\n/g).length-1];
|
|
79
|
+
const preview = {...base,text,lines,nextOffset:lines[1]+1};
|
|
80
|
+
if (formatReturn(preview).length <= maxReturn-256) return preview;
|
|
81
|
+
room = Math.floor(room / 2);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function presentValue(value, maxReturn, oversized) {
|
|
87
|
+
if (isString(value)) {
|
|
88
|
+
const bounded = truncateChars(value,maxReturn,"return");
|
|
89
|
+
const result = serializeReturn(value,formatReturn(bounded.text),maxReturn,false);
|
|
90
|
+
result.truncated ||= bounded.truncated;
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
if (isStringArray(value) && oversized) {
|
|
94
|
+
return {text:formatBoundedStringArray(value,maxReturn),truncated:true};
|
|
95
|
+
}
|
|
96
|
+
if (oversized) return {text:formatBoundedValue(value,maxReturn),truncated:true};
|
|
97
|
+
return serializeReturn(value,formatReturn(value),maxReturn,false);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function packageFinalReturn(value, logs, config) {
|
|
101
|
+
const acc = { images: [], imageCount: 0, imageBytes: 0, imageOverflow: false, displayUnits:0 };
|
|
102
|
+
value = collectImages(value, acc);
|
|
103
|
+
if (acc.imageOverflow) {
|
|
104
|
+
throw new Error(`image attachment budget exceeded: ${acc.imageCount} images / ${acc.imageBytes} bytes; limit is 16 images / 20971520 bytes (20 MiB). No images returned; return fewer or smaller images per program`);
|
|
105
|
+
}
|
|
106
|
+
const maxReturn = config.maxReturnChars ?? 32000;
|
|
107
|
+
const preview = sourcePreview(value,maxReturn);
|
|
108
|
+
if (preview) value = preview;
|
|
109
|
+
const serialized = presentValue(value,maxReturn,!preview && acc.displayUnits>maxReturn);
|
|
110
|
+
const clipped = clipLogs(logs, config);
|
|
111
|
+
|
|
112
|
+
return { returnValue: serialized.truncated ? serialized.text : value, returnText: serialized.text,
|
|
113
|
+
returnTruncated: serialized.truncated || Boolean(preview), logs: clipped.logs, logTruncated: clipped.logTruncated, images: acc.images };
|
|
114
|
+
}
|
package/src/output/format.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isString, isObject, mapChangedChildren } from "../shared/decode.js";
|
|
2
|
+
import {maxJsonStringPrefix} from "../fs/json-size.js";
|
|
2
3
|
|
|
3
4
|
function normalizeText(text) {
|
|
4
5
|
return isString(text) ? text : String(text ?? "");
|
|
@@ -76,24 +77,40 @@ function hasUnpairedSurrogate(text) {
|
|
|
76
77
|
return SURROGATE_CODE_UNIT.test(text) && UNPAIRED_SURROGATE.test(text);
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
export function isStringArray(value) {
|
|
81
|
+
if (!Array.isArray(value) || !value.length) return false;
|
|
82
|
+
for (const item of value) if (!isString(item)) return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
79
86
|
function hasWellFormedStrings(values) {
|
|
80
87
|
for (const value of values) if (!isString(value) || hasUnpairedSurrogate(value)) return false;
|
|
81
88
|
|
|
82
89
|
return true;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
|
|
92
|
+
function boundedStringText(value, limit) {
|
|
93
|
+
const text = truncateChars(value,limit,"return").text;
|
|
94
|
+
return hasUnpairedSurrogate(text) ? truncateChars(JSON.stringify(text),limit,"return").text : text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Give displayed array items a fair share; disclose any omitted items. */
|
|
86
98
|
export function formatBoundedStringArray(values, budget) {
|
|
87
99
|
const n = values.length;
|
|
88
100
|
const header = "strings[" + n + "]\n";
|
|
89
101
|
let remaining = Math.max(0, budget - header.length);
|
|
90
102
|
let out = header;
|
|
103
|
+
if (header.length >= budget) return truncateChars(header,budget,"return").text;
|
|
104
|
+
const omitted = count => "…[return truncated; " + count + " string items omitted]…\n";
|
|
91
105
|
|
|
92
106
|
for (let i = 0; i < n; i++) {
|
|
93
107
|
const itemHeader = "[" + i + "] " + values[i].length + " UTF-16 units\n";
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
const footer = i + 1 < n ? omitted(n - i - 1) : "";
|
|
109
|
+
if (remaining < itemHeader.length + Math.min(32,values[i].length) + 1 + footer.length) {
|
|
110
|
+
return out + truncateChars(omitted(n - i),remaining,"return").text;
|
|
111
|
+
}
|
|
112
|
+
const per = Math.min(remaining - itemHeader.length - 1 - footer.length, Math.max(32, Math.floor(remaining / (n - i)) - itemHeader.length - 1));
|
|
113
|
+
const chunk = itemHeader + boundedStringText(values[i],per) + "\n";
|
|
97
114
|
remaining = Math.max(0, remaining - chunk.length);
|
|
98
115
|
out += chunk;
|
|
99
116
|
}
|
|
@@ -149,7 +166,7 @@ function framedReturn(value) {
|
|
|
149
166
|
|
|
150
167
|
/** Lossless framing for source arrays, not string escaping or source compression. */
|
|
151
168
|
export function formatReturn(value) {
|
|
152
|
-
if (isString(value)) return value;
|
|
169
|
+
if (isString(value)) return hasUnpairedSurrogate(value) ? JSON.stringify(value) : value;
|
|
153
170
|
const rawArray = formatRawStringArray(value);
|
|
154
171
|
|
|
155
172
|
if (rawArray !== null) return rawArray;
|
|
@@ -287,3 +304,75 @@ export function formatValue(value, indent = "", width = FORMAT_WIDTH, seen = new
|
|
|
287
304
|
seen.delete(value);
|
|
288
305
|
}
|
|
289
306
|
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
function displayKeys(value) {
|
|
310
|
+
return Array.isArray(value) ? value.keys() : Object.keys(value);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A cheap lower bound stops before expanding large/shared result subtrees. */
|
|
314
|
+
export function displayExceeds(value, budget) {
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
const spend = units => (budget -= units) < 0;
|
|
317
|
+
function container(item) {
|
|
318
|
+
if (seen.has(item)) return false;
|
|
319
|
+
seen.add(item);
|
|
320
|
+
const array = Array.isArray(item);
|
|
321
|
+
if (array && spend(item.length)) return true;
|
|
322
|
+
for (const key of displayKeys(item)) {
|
|
323
|
+
if (!array && spend(key.length+1)) return true;
|
|
324
|
+
if (visit(item[key])) return true;
|
|
325
|
+
}
|
|
326
|
+
seen.delete(item);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
function visit(item) {
|
|
330
|
+
if (spend(isString(item) ? item.length : 1)) return true;
|
|
331
|
+
return isObject(item) || Array.isArray(item) ? container(item) : false;
|
|
332
|
+
}
|
|
333
|
+
return visit(value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Display-only prefix; never constructs an oversized JSON/string rendering. */
|
|
337
|
+
export function formatBoundedValue(value, budget, label = "return") {
|
|
338
|
+
const footer = "\n…["+label+" truncated]…";
|
|
339
|
+
const parts = [];
|
|
340
|
+
const seen = new Set();
|
|
341
|
+
let remaining = Math.max(0,budget-footer.length), stopped = false;
|
|
342
|
+
const push = text => {
|
|
343
|
+
if (text.length > remaining) { stopped = true; return false; }
|
|
344
|
+
remaining -= text.length; parts.push(text); return true;
|
|
345
|
+
};
|
|
346
|
+
function quoted(text) {
|
|
347
|
+
const end = maxJsonStringPrefix(text,remaining);
|
|
348
|
+
push(JSON.stringify(text.slice(0,end)));
|
|
349
|
+
if (end < text.length) stopped = true;
|
|
350
|
+
}
|
|
351
|
+
function visitChild(item,key,array) {
|
|
352
|
+
if (!array) { quoted(key); push(":"); }
|
|
353
|
+
visit(array && item[key] === undefined ? null : item[key]);
|
|
354
|
+
}
|
|
355
|
+
function container(item) {
|
|
356
|
+
if (seen.has(item)) { push('"[Circular]"'); return; }
|
|
357
|
+
seen.add(item);
|
|
358
|
+
const array = Array.isArray(item);
|
|
359
|
+
push(array ? "[" : "{");
|
|
360
|
+
let first = true;
|
|
361
|
+
for (const key of displayKeys(item)) {
|
|
362
|
+
if (stopped) break;
|
|
363
|
+
if (!first) push(",");
|
|
364
|
+
first = false;
|
|
365
|
+
visitChild(item,key,array);
|
|
366
|
+
}
|
|
367
|
+
if (!stopped) push(array ? "]" : "}");
|
|
368
|
+
seen.delete(item);
|
|
369
|
+
}
|
|
370
|
+
function visit(item) {
|
|
371
|
+
if (stopped) return;
|
|
372
|
+
if (isString(item)) quoted(item);
|
|
373
|
+
else if (isObject(item) || Array.isArray(item)) container(item);
|
|
374
|
+
else push(formatPrimitive(item));
|
|
375
|
+
}
|
|
376
|
+
visit(value);
|
|
377
|
+
return truncateChars(parts.join("")+footer,budget,"return").text;
|
|
378
|
+
}
|