pi-supernova 0.7.1 → 0.8.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 +127 -44
- package/docs/CHANGELOG.md +87 -0
- package/docs/TOKEN_COSTS.md +151 -3
- package/index.js +5 -3
- package/package.json +1 -1
- package/src/adapters/bash.js +8 -1
- package/src/adapters/edit.js +2 -1
- package/src/adapters/errors.js +1 -1
- package/src/adapters/read.js +28 -7
- package/src/adapters/write.js +14 -5
- package/src/bridge/host-bridge.js +3 -1
- package/src/context/evidence.js +19 -2
- package/src/contract/bash.js +15 -1
- package/src/contract/edit.js +21 -3
- package/src/contract/read.js +15 -0
- package/src/fs/check.js +14 -5
- package/src/fs/text-ops.js +28 -3
- package/src/fs/vfs.js +21 -7
- package/src/fs/workspace.js +8 -3
- package/src/output/bottleneck.js +11 -12
- package/src/output/format.js +24 -16
- package/src/runtime/guest-worker.js +1 -1
- package/src/runtime/program-batch.js +43 -24
- package/src/runtime/program-file.js +4 -1
- package/src/runtime/reference.js +14 -18
- package/src/runtime/runtime.js +17 -4
- package/src/shared/decode.js +30 -0
- package/src/shared/syntax-context.js +31 -0
- package/src/shared/utf8.js +17 -0
- package/src/ui/render.js +37 -13
package/src/output/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isString, isObject } from "../shared/decode.js";
|
|
1
|
+
import { isString, isObject, mapChangedChildren } from "../shared/decode.js";
|
|
2
2
|
|
|
3
3
|
function normalizeText(text) {
|
|
4
4
|
return isString(text) ? text : String(text ?? "");
|
|
@@ -109,33 +109,41 @@ function formatRawStringArray(value) {
|
|
|
109
109
|
return raw.length < escapedSize ? raw : null;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function
|
|
113
|
-
|
|
112
|
+
function rawStringSize(input) {
|
|
113
|
+
if (!isString(input) || !input.includes("\n") || hasUnpairedSurrogate(input)) return 0;
|
|
114
|
+
const size = JSON.stringify(input).length;
|
|
115
|
+
|
|
116
|
+
return size - input.length > 64 ? size : 0;
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
function visitRawStrings(input,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
function visitRawStrings(input, acc) {
|
|
120
|
+
const size = rawStringSize(input);
|
|
121
|
+
|
|
122
|
+
if (size) {
|
|
123
|
+
const index = acc.strings.push(input) - 1;
|
|
124
|
+
acc.escapedChars += size;
|
|
119
125
|
|
|
120
126
|
return { [RAW_TEXT]: "raw[" + index + "]" };
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visitRawStrings(child, strings)]));
|
|
126
|
-
|
|
127
|
-
return input;
|
|
129
|
+
return mapChangedChildren(input, visitRawStrings, acc);
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
function framedReturn(value
|
|
131
|
-
const
|
|
132
|
-
const referencedValue = visitRawStrings(value,
|
|
132
|
+
function framedReturn(value) {
|
|
133
|
+
const acc = { strings: [], escapedChars: 0 };
|
|
134
|
+
const referencedValue = visitRawStrings(value, acc);
|
|
135
|
+
const { strings } = acc;
|
|
133
136
|
|
|
134
|
-
if (!strings.length) return
|
|
137
|
+
if (!strings.length) return formatValue(value);
|
|
135
138
|
// Keep every key, value, duplicate string and byte. References are unquoted
|
|
136
139
|
// expressions, so literal "raw[0]" values and header-like source cannot collide.
|
|
137
140
|
const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
|
|
138
141
|
|
|
142
|
+
// The ordinary rendering contains at least these complete escaped literals.
|
|
143
|
+
// If framing beats even that lower bound, do not build the discarded rendering.
|
|
144
|
+
if (framed.length < acc.escapedChars) return framed;
|
|
145
|
+
const escaped = formatValue(value);
|
|
146
|
+
|
|
139
147
|
return framed.length < escaped.length ? framed : escaped;
|
|
140
148
|
}
|
|
141
149
|
|
|
@@ -146,7 +154,7 @@ export function formatReturn(value) {
|
|
|
146
154
|
|
|
147
155
|
if (rawArray !== null) return rawArray;
|
|
148
156
|
|
|
149
|
-
return framedReturn(value
|
|
157
|
+
return framedReturn(value);
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
const RAW_TEXT = Symbol("raw text reference");
|
|
@@ -242,7 +242,7 @@ async function runSpeculation(fn, token, checkpointScope, drainReads, enqueueHos
|
|
|
242
242
|
|
|
243
243
|
if (began) await enqueueHost(() => rpc("speculateRollback", []));
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
throw err;
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
@@ -28,42 +28,56 @@ export function programBatchText(results, total, stopped = "", failed = 0) {
|
|
|
28
28
|
}).join("");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function assertProgramEntry(p) {
|
|
31
|
+
function assertProgramEntry(p, defaults = {}) {
|
|
32
|
+
const source = p?.code === undefined && p?.file === undefined ? defaults : p;
|
|
33
|
+
|
|
32
34
|
if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
|
|
33
|
-
((
|
|
34
|
-
throw new Error("each program requires code OR file, with optional data; no nested batches or per-entry timeouts; no programs ran");
|
|
35
|
+
((source.code === undefined) === (source.file === undefined)) || !isString(source.code ?? source.file) || !(source.code ?? source.file).trim()) {
|
|
36
|
+
throw new Error("each program requires code OR file (own or shared), with optional data; no nested batches or per-entry timeouts; no programs ran");
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
const hasDefault = params.data !== undefined;
|
|
40
|
-
let encoded;
|
|
40
|
+
const objectData = value => isObject(value) && !Array.isArray(value);
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
function applyBatchDefaults(parsed, mergeData) {
|
|
43
|
+
if (mergeData && !objectData(parsed.data)) throw new Error("mergeData requires top-level object data; no programs ran");
|
|
44
|
+
const source = parsed.code !== undefined ? {code:parsed.code} : parsed.file !== undefined ? {file:parsed.file} : {};
|
|
43
45
|
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
+
return parsed.programs.map(program => {
|
|
47
|
+
assertProgramEntry(program, source);
|
|
48
|
+
const entry = program.code === undefined && program.file === undefined ? {...source,...program} : program;
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
if (mergeData) {
|
|
51
|
+
if (program.data !== undefined && !objectData(program.data)) throw new Error("mergeData requires object data in every explicit entry; no programs ran");
|
|
52
|
+
// Shallow own-property overlay, including literal __proto__ keys. The
|
|
53
|
+
// runtime snapshots data again per guest; no mutable heap is shared.
|
|
54
|
+
entry.data = {...parsed.data,...program.data};
|
|
55
|
+
} else if (program.data === undefined && Object.hasOwn(parsed,"data")) entry.data = parsed.data;
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return parsed.programs.map(program => program.data === undefined ? {...program,data:parsed.data} : program);
|
|
57
|
+
return entry;
|
|
58
|
+
});
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function parseBatchPayload(params, config) {
|
|
57
|
-
if (["code","file"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code or file; no programs ran");
|
|
58
|
-
|
|
59
62
|
if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
|
|
63
|
+
if (params.mergeData !== undefined && params.mergeData !== true && params.mergeData !== false) throw new Error("mergeData must be boolean; no programs ran");
|
|
64
|
+
const defaults = Object.fromEntries(["code","file","data"].filter(key => params[key] !== undefined).map(key => [key,params[key]]));
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
const
|
|
66
|
+
if (defaults.code !== undefined || defaults.file !== undefined) assertProgramEntry(defaults);
|
|
67
|
+
for (const p of params.programs) assertProgramEntry(p, defaults);
|
|
68
|
+
const hasDefaults = Object.keys(defaults).length > 0;
|
|
69
|
+
let encoded;
|
|
70
|
+
|
|
71
|
+
try { encoded = JSON.stringify(hasDefaults ? {programs:params.programs,...defaults} : params.programs); } catch { throw new Error("programs and defaults must be JSON-serializable; no programs ran"); }
|
|
63
72
|
|
|
64
|
-
if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
|
|
73
|
+
if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget (including shared code/file/data); no programs ran");
|
|
74
|
+
const parsed = hasDefaults ? JSON.parse(encoded) : {programs:JSON.parse(encoded)};
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
|
|
77
|
+
|
|
78
|
+
// Validate and expand every entry before executing any. Defaults count once
|
|
79
|
+
// against admission, not once for each independent guest receiving a copy.
|
|
80
|
+
return applyBatchDefaults(parsed, params.mergeData === true);
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
function batchTimeoutMs(params, config) {
|
|
@@ -132,6 +146,10 @@ class ProgramBatch {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
|
|
149
|
+
/** Elapsed and limit: 'deadline or cancellation' alone cannot tell them apart. */
|
|
150
|
+
deadlineNote() {
|
|
151
|
+
return " (ran " + Math.round(performance.now() - this.started) + "ms of " + this.timeout + "ms)";
|
|
152
|
+
}
|
|
135
153
|
takeSettled(result, i) {
|
|
136
154
|
this.results.push(result);
|
|
137
155
|
this.trace.push(...(result.details?.trace ?? []));
|
|
@@ -156,7 +174,7 @@ class ProgramBatch {
|
|
|
156
174
|
this.takeSettled(settled[i], i);
|
|
157
175
|
}
|
|
158
176
|
|
|
159
|
-
if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain";
|
|
177
|
+
if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
|
|
160
178
|
}
|
|
161
179
|
|
|
162
180
|
sequentialStop(result, i) {
|
|
@@ -171,14 +189,15 @@ class ProgramBatch {
|
|
|
171
189
|
|
|
172
190
|
if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
|
|
173
191
|
|
|
174
|
-
|
|
192
|
+
// The deadline explains a killed program better than "program N failed".
|
|
193
|
+
if (this.combined.aborted || performance.now() >= this.deadline) stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
|
|
175
194
|
|
|
176
195
|
return stopped;
|
|
177
196
|
}
|
|
178
197
|
|
|
179
198
|
async runSequential() {
|
|
180
199
|
for (const [i, program] of this.programs.entries()) {
|
|
181
|
-
if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run"; break; }
|
|
200
|
+
if (this.combined.aborted || performance.now() >= this.deadline) { this.stopped = "deadline or cancellation; remaining programs did not run" + this.deadlineNote(); break; }
|
|
182
201
|
|
|
183
202
|
const result = await this.runOne(program, i);
|
|
184
203
|
this.live[i] = [];
|
|
@@ -34,7 +34,10 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
|
|
|
34
34
|
|
|
35
35
|
signal?.throwIfAborted();
|
|
36
36
|
// Do not silently replace invalid bytes in executable source. Preserve BOMs.
|
|
37
|
-
|
|
37
|
+
let code;
|
|
38
|
+
|
|
39
|
+
try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); }
|
|
40
|
+
catch { throw new Error("program file " + file + " is not valid UTF-8 (encoded data could not be decoded); save it as UTF-8 text"); }
|
|
38
41
|
|
|
39
42
|
if (code.length > chars) throw tooLarge();
|
|
40
43
|
|
package/src/runtime/reference.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
// Standing tool description: sent on every request.
|
|
2
|
-
export const REFERENCE = `
|
|
3
|
-
|
|
4
|
-
read(path
|
|
5
|
-
read(
|
|
6
|
-
read({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
bash({command,args}) → literal argv
|
|
16
|
-
|
|
17
|
-
edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files. Array reads reject failures. Edits stage until success; bash commits preceding writes. Batch known reads, edits, and tests in this program (Promise.all, or programs with parallel:true).
|
|
18
|
-
programs:[{code|file,data?},...] sequential fresh guests, separate commits; top-level data defaults per entry. Stop on failure keeps earlier commits. parallel:true runs disjoint entries concurrently. Separate supernova calls only when the next step needs a model decision.
|
|
1
|
+
// Standing tool description: sent on every request. No result or history compression.
|
|
2
|
+
export const REFERENCE = `JS body/async arrow with read/write/edit/bash; no fs/import/require. file runs workspace scripts. data holds literal text/scripts/argv (≤48000 serialized JSON chars).
|
|
3
|
+
read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 attachments/20 MiB total).
|
|
4
|
+
Path-only: ≤160 lines AND 8192 characters (UTF-16). Use read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded parser via bash.
|
|
5
|
+
read({path,json:selector}) → parsed JSON; selectors ".field", ".a[0:3]", ".a.length", quoted keys, true; 16 MiB input, no jq. Oversized → {status:"too_large",keys} (arrays: length); narrow selectors.
|
|
6
|
+
read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
|
|
7
|
+
read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
|
|
8
|
+
write(path,text) replaces unread workspace files; write({path,content,append:true}) appends without reading. After read: edit or replace:true.
|
|
9
|
+
edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) exact unique read text; numbered windows (including misses), checks/references.
|
|
10
|
+
edit(view,text) replaces the span; edit(view,old,new) matches uniquely within it. edit(async()=>{...}) checkpoint merges on success, rolls back/rethrows on failure; catch to recover.
|
|
11
|
+
bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv; bounded output, nonzero throws; inherits program timeout unless overridden.
|
|
12
|
+
Edits stage until success; bash commits first. Array errors abort; Promise.allSettled for optional reads.
|
|
13
|
+
programs:[{code?,file?,data?}] inherits top-level code OR file and data. Entry source overrides; data replaces unless mergeData:true (shallow objects, entry keys win).
|
|
14
|
+
Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known work; separate calls only for new decisions.
|
|
19
15
|
`;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { packageFinalReturn } from "../output/bottleneck.js";
|
|
|
6
6
|
import { truncateChars } from "../output/format.js";
|
|
7
7
|
import { isFunction, isObject, isString } from "../shared/decode.js";
|
|
8
8
|
import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
|
|
9
|
+
import { errorContext } from "../shared/syntax-context.js";
|
|
9
10
|
|
|
10
11
|
const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
|
|
11
12
|
|
|
@@ -221,7 +222,7 @@ function admitData(data, cap) {
|
|
|
221
222
|
const encoded = JSON.stringify(data);
|
|
222
223
|
|
|
223
224
|
if (encoded === undefined) return { error: "data must be JSON-serializable" };
|
|
224
|
-
if (encoded.length > cap) return { error: "data exceeds " + cap + " characters;
|
|
225
|
+
if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
|
|
225
226
|
|
|
226
227
|
return { data: JSON.parse(encoded) };
|
|
227
228
|
} catch { return { error: "data must be JSON-serializable" }; }
|
|
@@ -327,7 +328,7 @@ class GuestRun {
|
|
|
327
328
|
try { this.onTimeout?.(); } catch {}
|
|
328
329
|
|
|
329
330
|
this.aborting = false;
|
|
330
|
-
this.finish(this.fail(ABORT_MESSAGE));
|
|
331
|
+
this.finish(this.fail(ABORT_MESSAGE + " (ran " + Math.round(this.wall()) + "ms of " + this.timeoutMs + "ms)"));
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
postResult(message) {
|
|
@@ -346,7 +347,12 @@ class GuestRun {
|
|
|
346
347
|
|
|
347
348
|
async drainPending(outcome) {
|
|
348
349
|
if (this.pending.size || !outcome.ok) this.cancelHost();
|
|
349
|
-
|
|
350
|
+
if (!this.pending.size) return;
|
|
351
|
+
let timer;
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await Promise.race([Promise.allSettled(this.pending), new Promise(resolve => { timer = setTimeout(resolve, 250); })]);
|
|
355
|
+
} finally { clearTimeout(timer); }
|
|
350
356
|
if (this.pending.size && outcome.ok) this.hostError ??= "program completed with a host call still running";
|
|
351
357
|
}
|
|
352
358
|
|
|
@@ -429,7 +435,14 @@ class GuestRun {
|
|
|
429
435
|
if (!this.code.trim()) { this.finish(this.fail("code must be a non-empty string; no commands ran")); return false; }
|
|
430
436
|
|
|
431
437
|
try { this.prepared = prepareProgram(this.code); }
|
|
432
|
-
catch (error) {
|
|
438
|
+
catch (error) {
|
|
439
|
+
const guidance = this.file === undefined
|
|
440
|
+
? " Put literal file/script content in the tool's data parameter and use write(data.path,data.content) or bash({command,args:data.args})."
|
|
441
|
+
: " Fix " + this.file + " and re-run.";
|
|
442
|
+
|
|
443
|
+
this.finish(this.fail("JavaScript syntax error" + (this.file === undefined ? "" : " in " + this.file) + ": " + error.message + "; no commands ran." + guidance + errorContext(this.code, error)));
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
433
446
|
|
|
434
447
|
return true;
|
|
435
448
|
}
|
package/src/shared/decode.js
CHANGED
|
@@ -19,6 +19,36 @@ export function looksLikePath(target) {
|
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Transform children without copying unchanged result trees. Never mutate the input. */
|
|
23
|
+
export function mapChangedChildren(value, visit, context) {
|
|
24
|
+
const array = Array.isArray(value);
|
|
25
|
+
|
|
26
|
+
if (!array && !isObject(value)) return value;
|
|
27
|
+
let out = value;
|
|
28
|
+
|
|
29
|
+
for (const key of array ? value.keys() : Object.keys(value)) {
|
|
30
|
+
if (array && !(key in value)) continue;
|
|
31
|
+
const before = value[key];
|
|
32
|
+
const after = visit(before, context);
|
|
33
|
+
|
|
34
|
+
if (Object.is(before, after)) continue;
|
|
35
|
+
if (out === value) out = array ? value.slice() : { ...value };
|
|
36
|
+
// Define rather than assign: "__proto__" must remain an ordinary data key.
|
|
37
|
+
Object.defineProperty(out, key, { value: after, enumerable: true, writable: true, configurable: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MODEL_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
44
|
+
|
|
45
|
+
/** Reject unsupported attachments before they can poison the next model request. */
|
|
46
|
+
export function assertModelImageMime(mimeType) {
|
|
47
|
+
if (!MODEL_IMAGE_MIMES.has(mimeType)) {
|
|
48
|
+
throw new Error("unsupported image attachment type " + mimeType + "; model images require PNG, JPEG, GIF, or WebP. Convert the image to PNG before reading/returning it; no image attached");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
const MAX_DEPTH = 64;
|
|
23
53
|
|
|
24
54
|
const MAX_TYPED_ARRAY = 4096;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Bounded source context for parse and syntax diagnostics. */
|
|
2
|
+
|
|
3
|
+
export function sourceContext(source, line, column) {
|
|
4
|
+
if (!Number.isInteger(line) || line < 1) return "";
|
|
5
|
+
const text = String(source).split("\n")[line - 1];
|
|
6
|
+
|
|
7
|
+
if (text === undefined) return "";
|
|
8
|
+
const shown = text.length > 160 ? text.slice(0, 160) : text;
|
|
9
|
+
const caret = Number.isInteger(column) ? " ".repeat(Math.min(column, shown.length)) + "^" : "";
|
|
10
|
+
|
|
11
|
+
return "\n " + shown + (caret ? "\n " + caret : "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** acorn-style error that carries a loc, when it has one. */
|
|
15
|
+
export function errorContext(source, error) {
|
|
16
|
+
return sourceContext(source, error?.loc?.line, error?.loc?.column);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** V8 "(line 5 column 3)" or "at position 42" parse messages → a position. */
|
|
20
|
+
export function parsePosition(message, source) {
|
|
21
|
+
const located = /\(line (\d+) column (\d+)\)/.exec(String(message));
|
|
22
|
+
|
|
23
|
+
if (located) return { line: Number(located[1]), column: Number(located[2]) };
|
|
24
|
+
const offsetMatch = /at position (\d+)/.exec(String(message));
|
|
25
|
+
|
|
26
|
+
if (!offsetMatch) return null;
|
|
27
|
+
const before = String(source).slice(0, Number(offsetMatch[1]));
|
|
28
|
+
const lines = before.split("\n");
|
|
29
|
+
|
|
30
|
+
return { line: lines.length, column: lines.at(-1).length };
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Strict decode: binary or non-UTF-8 text must fail loudly, never as U+FFFD. */
|
|
2
|
+
export function decodeUtf8Strict(bytes, target) {
|
|
3
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
|
4
|
+
catch {
|
|
5
|
+
throw new Error("not valid UTF-8 (binary or non-UTF-8 text): " + target + "; inspect or convert it with bash (for example iconv -f latin1 -t utf8 or xxd)");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Prefix window: the byte cut may split a character, so drop one partial tail. */
|
|
10
|
+
export function decodeUtf8Window(bytes) {
|
|
11
|
+
for (let cut = 0; cut <= 3 && cut < bytes.length; cut++) {
|
|
12
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytes.length - cut)); }
|
|
13
|
+
catch { /* a partial character at the cut is expected */ }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return bytes.toString("utf8");
|
|
17
|
+
}
|
package/src/ui/render.js
CHANGED
|
@@ -373,6 +373,8 @@ function formatTarget(op, budget) {
|
|
|
373
373
|
function opMarker(theme, op, isPartial, isError) {
|
|
374
374
|
if (op.ok === false) return theme.fg("error", "×");
|
|
375
375
|
|
|
376
|
+
if (op.mutationAttempt) return theme.fg("warning", "·");
|
|
377
|
+
|
|
376
378
|
if (op.ok === true) return theme.fg("success", "✓");
|
|
377
379
|
|
|
378
380
|
if (isPartial) return theme.fg("dim", "·");
|
|
@@ -430,8 +432,9 @@ function formatOpRow(theme, op, width, isPartial, isError) {
|
|
|
430
432
|
const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
|
|
431
433
|
const exit = appendExit(theme, op);
|
|
432
434
|
const counts = appendDiffCounts(theme, op);
|
|
433
|
-
const
|
|
434
|
-
const
|
|
435
|
+
const outcome = op.mutationAttempt ? "attempted " : "";
|
|
436
|
+
const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text + theme.fg("warning", outcome);
|
|
437
|
+
const used = 2 + toolText.length + 1 + DURATION_COL + 2 + exit.width + counts.width + outcome.length;
|
|
435
438
|
|
|
436
439
|
return opRowSuffix(theme, op, prefix, Math.max(1, width - used));
|
|
437
440
|
}
|
|
@@ -460,10 +463,22 @@ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, is
|
|
|
460
463
|
if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
|
|
461
464
|
}
|
|
462
465
|
|
|
463
|
-
function appendError(lines, theme, payload,
|
|
464
|
-
const
|
|
466
|
+
function appendError(lines, theme, payload, _expanded, width) {
|
|
467
|
+
const errors = [payload?.error || "error"];
|
|
468
|
+
for (const [i, program] of (payload?.programs ?? []).entries()) {
|
|
469
|
+
if (program.details?.ok === false) errors.push(`program ${i + 1}: ${program.details.error || resultTextContent(program)}`);
|
|
470
|
+
}
|
|
471
|
+
for (const error of errors) for (const line of resultLines("✗ " + cleanBlockText(error), width)) lines.push(theme.fg("error", line));
|
|
472
|
+
}
|
|
465
473
|
|
|
466
|
-
|
|
474
|
+
function appendMutations(lines, theme, payload, width) {
|
|
475
|
+
const m = payload?.mutations;
|
|
476
|
+
if (!m || !(m.committed || m.rolledBack || m.external || m.pendingCommits || m.recoveryFailed)) return;
|
|
477
|
+
const summary = `file versions: committed=${m.committed || 0} rolledBack=${m.rolledBack || 0}`
|
|
478
|
+
+ (m.external ? `; external calls attempted=${m.external}` : "")
|
|
479
|
+
+ (m.pendingCommits ? `; pendingCommits=${m.pendingCommits}` : "")
|
|
480
|
+
+ (m.recoveryFailed ? "; recovery failed: inspect files" : "");
|
|
481
|
+
for (const line of resultLines(summary, width)) lines.push(theme.fg(m.rolledBack || m.recoveryFailed ? "warning" : "dim", line));
|
|
467
482
|
}
|
|
468
483
|
|
|
469
484
|
function appendResult(lines, theme, payload, expanded, width) {
|
|
@@ -502,17 +517,22 @@ function appendOverflow(lines, theme, trace, maxOps, isPartial) {
|
|
|
502
517
|
}
|
|
503
518
|
|
|
504
519
|
function appendEmptyOps(lines, theme, ops, isError, isPartial) {
|
|
505
|
-
if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "
|
|
520
|
+
if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "JavaScript-only execution"));
|
|
506
521
|
}
|
|
507
522
|
|
|
508
523
|
function buildBodyLines(theme, width, { payload, context, expanded, isPartial, isError }) {
|
|
509
524
|
const trace = traceFor(payload, context);
|
|
510
525
|
const { maxOps, maxDiffLines } = bodyLimits(expanded, isPartial);
|
|
511
526
|
const ops = operationsFromTrace(visibleTrace(trace, maxOps, isPartial));
|
|
527
|
+
// Trace success records an operation, not persistence of every staged version.
|
|
528
|
+
// Mixed rollback/commit counts cannot safely be attributed to individual rows.
|
|
529
|
+
for (const op of ops) op.mutationAttempt = op.ok === true && ["write", "edit", "patch"].includes(op.tool)
|
|
530
|
+
&& (isPartial || isError || payload?.mutations?.rolledBack > 0 || payload?.mutations?.recoveryFailed);
|
|
512
531
|
const lines = [];
|
|
513
532
|
appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
|
|
514
533
|
appendOverflow(lines, theme, trace, maxOps, isPartial);
|
|
515
|
-
|
|
534
|
+
if (!isPartial) appendMutations(lines, theme, payload, width);
|
|
535
|
+
if (Array.isArray(payload?.trace)) appendEmptyOps(lines, theme, ops, isError, isPartial);
|
|
516
536
|
appendTail(lines, theme, payload, expanded, isError, width, ops.length === 0 && !isPartial);
|
|
517
537
|
|
|
518
538
|
return { lines, opCount: trace.length };
|
|
@@ -594,12 +614,14 @@ function syncState(context, payload) {
|
|
|
594
614
|
if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) context.state.wallMs = payload.wallMs;
|
|
595
615
|
}
|
|
596
616
|
|
|
597
|
-
function
|
|
598
|
-
return result?.
|
|
617
|
+
function resultTextContent(result) {
|
|
618
|
+
return result?.content?.flatMap(block => block.type === "text" ? [block.text] : []).join("\n") || "";
|
|
599
619
|
}
|
|
600
620
|
|
|
601
|
-
function payloadFromResult(result) {
|
|
602
|
-
|
|
621
|
+
function payloadFromResult(result, hostError) {
|
|
622
|
+
const payload = result?.details;
|
|
623
|
+
if (hostError) return {...payload, ok:false, error:payload?.error || resultTextContent(result) || "tool execution failed"};
|
|
624
|
+
return payload ?? {result:resultTextContent(result)};
|
|
603
625
|
}
|
|
604
626
|
|
|
605
627
|
function bindResultCard(host, options, context) {
|
|
@@ -619,9 +641,11 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
|
|
|
619
641
|
themeArg,
|
|
620
642
|
contextArg,
|
|
621
643
|
);
|
|
622
|
-
|
|
644
|
+
// Pi omits isError from result and supplies it through render context.
|
|
645
|
+
const hostError = result?.isError === true || context?.isError === true || options?.isError === true;
|
|
646
|
+
const payload = payloadFromResult(result, hostError);
|
|
623
647
|
syncState(context, payload);
|
|
624
|
-
const isError =
|
|
648
|
+
const isError = hostError || payload?.ok === false;
|
|
625
649
|
const comp = bindResultCard(host, options, context);
|
|
626
650
|
comp.set(theme, { payload, context, args, expanded, isPartial, isError, host });
|
|
627
651
|
|