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.
Files changed (67) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/fuzzy.js +116 -43
  21. package/src/context/query.js +80 -0
  22. package/src/context/repo-index.js +23 -166
  23. package/src/context/search-files.js +19 -0
  24. package/src/context/search.js +2 -24
  25. package/src/context/snap-search.js +203 -0
  26. package/src/context/snap.js +5 -266
  27. package/src/context/source-entry.js +112 -0
  28. package/src/contract/bash.js +6 -1
  29. package/src/contract/program.js +36 -0
  30. package/src/contract/read.js +8 -53
  31. package/src/fs/check.js +1 -1
  32. package/src/fs/commit.js +161 -0
  33. package/src/fs/diff.js +11 -15
  34. package/src/fs/directory.js +79 -0
  35. package/src/fs/file-io.js +100 -0
  36. package/src/fs/glob.js +54 -0
  37. package/src/fs/json-size.js +54 -0
  38. package/src/fs/lines.js +117 -0
  39. package/src/fs/read-window.js +74 -0
  40. package/src/fs/session-resource.js +50 -0
  41. package/src/fs/text-ops.js +7 -227
  42. package/src/fs/vfs.js +5 -239
  43. package/src/fs/workspace.js +2 -1
  44. package/src/output/bottleneck.js +13 -67
  45. package/src/output/final.js +114 -0
  46. package/src/output/format.js +94 -5
  47. package/src/output/outcome.js +91 -0
  48. package/src/runtime/batch-input.js +68 -0
  49. package/src/runtime/guest-api.js +281 -0
  50. package/src/runtime/guest-worker.js +62 -333
  51. package/src/runtime/parallel.js +41 -39
  52. package/src/runtime/program-batch.js +21 -75
  53. package/src/runtime/program-file.js +3 -11
  54. package/src/runtime/program.js +141 -0
  55. package/src/runtime/reference.js +6 -5
  56. package/src/runtime/runtime.js +77 -253
  57. package/src/runtime/worker-pool.js +91 -0
  58. package/src/shared/decode.js +22 -8
  59. package/src/shared/image-worker.js +30 -0
  60. package/src/shared/image.js +78 -0
  61. package/src/shared/png.js +57 -0
  62. package/src/shared/result.js +77 -0
  63. package/src/shared/syntax-context.js +61 -3
  64. package/src/ui/host-render.js +104 -0
  65. package/src/ui/progress.js +51 -0
  66. package/src/ui/render.js +21 -421
  67. package/src/ui/trace.js +277 -0
@@ -0,0 +1,91 @@
1
+ import {isString} from '../shared/decode.js';
2
+ import {truncateChars,formatBoundedStringArray,isStringArray} from './format.js';
3
+
4
+ function result(text, details) {
5
+ return { content: [{ type: "text", text }], details };
6
+ }
7
+
8
+ function logsBlock(outcome, tail = "") {
9
+ if (!outcome.logs?.length && !outcome.logTruncated) return "";
10
+
11
+ return `\n--- logs${outcome.logTruncated ? " [logs truncated]" : ""}\n${outcome.logs?.join("\n") ?? ""}${tail}`;
12
+ }
13
+
14
+ function mutationText(outcome) {
15
+ const m = outcome.mutations;
16
+
17
+ if (!m) return "";
18
+ const external = m.external ? "; external calls attempted=" + m.external + ", their side effects cannot be rolled back" : "";
19
+ const uncertain = m.pendingCommits || m.recoveryFailed ? "; filesystem outcome uncertain: inspect disk and any recovery backups before retrying" : "";
20
+
21
+ return "\nmutations: committed=" + m.committed + " rolledBack=" + m.rolledBack + " (file versions)" + external + uncertain;
22
+ }
23
+
24
+ function mutationReceipts(trace) {
25
+ if (!Array.isArray(trace)) return "";
26
+
27
+ return trace
28
+ .filter(row => row?.ok && (row.name === "write" || row.name === "edit") && isString(row.resultText) && row.resultText)
29
+ .map(row => row.resultText)
30
+ .join("\n");
31
+ }
32
+
33
+ // Corrective hint, emitted only when a turn actually split. Independent work
34
+ // belongs in one program: a split cannot use the single prewarmed worker and pays
35
+ // one extra spawn per sibling. Costs nothing until it fires, so it needs no room in
36
+ // the tool definition.
37
+ function splitTurnHint(outcome) {
38
+ return outcome.overlappedTurn ? ` (${outcome.overlappedTurn} supernova calls ran at once; independent work belongs in one program)` : "";
39
+ }
40
+
41
+ function errorText(outcome, call) {
42
+ return `error #${call} ${outcome.wallMs}ms${outcome.returnTruncated ? " [output truncated]" : ""}${mutationText(outcome)}${splitTurnHint(outcome)}
43
+ error: ${outcome.error}${logsBlock(outcome)}`;
44
+ }
45
+
46
+ function successText(outcome, call) {
47
+ const truncated = outcome.returnTruncated ? " [return truncated]" : "";
48
+ const hint = outcome.undefinedReturn ? " (no return statement; add `return` to get a value)" : "";
49
+ const m = outcome.mutations;
50
+ const showMutations = m && (m.committed || m.rolledBack || m.external || m.pendingCommits || m.recoveryFailed) ? mutationText(outcome) : "";
51
+
52
+ return `ok #${call} ${outcome.wallMs}ms${truncated}${showMutations}${splitTurnHint(outcome)}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
53
+ }
54
+
55
+ function fitOutput(outcome, call, limit, format) {
56
+ let text = format(outcome, call);
57
+
58
+ if (text.length <= limit) return text;
59
+ outcome.returnTruncated = true;
60
+ const wrapper = format({ ...outcome, resultText: "", logs: [] }, call);
61
+ const room = Math.max(256, limit - wrapper.length);
62
+
63
+ if (isStringArray(outcome.result)) {
64
+ outcome.resultText = formatBoundedStringArray(outcome.result, room);
65
+ } else if (isString(outcome.resultText) && outcome.resultText.length > room) {
66
+ outcome.resultText = truncateChars(outcome.resultText, room, "output").text;
67
+ }
68
+
69
+ text = format(outcome, call);
70
+
71
+ return text.length <= limit ? text : truncateChars(text, limit, "output").text;
72
+ }
73
+
74
+ function attachReceipts(outcome, trace) {
75
+ if (outcome.ok && outcome.result === undefined) {
76
+ const receipts = mutationReceipts(trace);
77
+
78
+ if (receipts) {
79
+ outcome.resultText = receipts;
80
+ outcome.undefinedReturn = false;
81
+ }
82
+ }
83
+ }
84
+
85
+ function throwIfFailed(outcome, visible, response) {
86
+ if (outcome.ok) return response;
87
+ const error = new Error(visible);
88
+ Object.defineProperty(error,"supernovaResult",{value:response});
89
+ throw error;
90
+ }
91
+ export { result, errorText, successText, fitOutput, attachReceipts, throwIfFailed };
@@ -0,0 +1,68 @@
1
+ import {isObject,isString} from '../shared/decode.js';
2
+
3
+ function assertProgramEntry(p, defaults = {}) {
4
+ const source = p?.code === undefined && p?.file === undefined ? defaults : p;
5
+ if (!objectData(p) || Object.keys(p).some(key => !["code", "file", "data"].includes(key)) || !validProgramSource(source)) {
6
+ 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");
7
+ }
8
+ }
9
+
10
+ const objectData = value => isObject(value) && !Array.isArray(value);
11
+
12
+ function applyBatchDefaults(parsed, mergeData) {
13
+ if (mergeData && !objectData(parsed.data)) throw new Error("mergeData requires top-level object data; no programs ran");
14
+ const source = parsed.code !== undefined ? {code:parsed.code} : parsed.file !== undefined ? {file:parsed.file} : {};
15
+
16
+ return parsed.programs.map(program => {
17
+ assertProgramEntry(program, source);
18
+ const entry = program.code === undefined && program.file === undefined ? {...source,...program} : program;
19
+
20
+ if (mergeData) {
21
+ if (program.data !== undefined && !objectData(program.data)) throw new Error("mergeData requires object data in every explicit entry; no programs ran");
22
+ // Shallow own-property overlay, including literal __proto__ keys. The
23
+ // runtime snapshots data again per guest; no mutable heap is shared.
24
+ entry.data = {...parsed.data,...program.data};
25
+ } else if (program.data === undefined && Object.hasOwn(parsed,"data")) entry.data = parsed.data;
26
+
27
+ return entry;
28
+ });
29
+ }
30
+
31
+ function parseBatchPayload(params, config) {
32
+ assertBatchOptions(params);
33
+ const defaults = Object.fromEntries(["code", "file", "data"].filter(key => params[key] !== undefined).map(key => [key, params[key]]));
34
+ if (defaults.code !== undefined || defaults.file !== undefined) assertProgramEntry(defaults);
35
+ for (const p of params.programs) assertProgramEntry(p, defaults);
36
+ // Validate before serialization and again after snapshotting: toJSON may change an entry.
37
+ return applyBatchDefaults(snapshotBatch(params, defaults, config), params.mergeData === true);
38
+ }
39
+
40
+ function batchTimeoutMs(params, config) {
41
+ const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
42
+
43
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
44
+
45
+ return requestedTimeout;
46
+ }
47
+
48
+ function validProgramSource(source) {
49
+ const value = source.code ?? source.file;
50
+ return (source.code === undefined) !== (source.file === undefined) && isString(value) && value.trim();
51
+ }
52
+
53
+ function assertBatchOptions(params) {
54
+ if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
55
+ if (params.mergeData !== undefined && ![true, false].includes(params.mergeData)) throw new Error("mergeData must be boolean; no programs ran");
56
+ }
57
+
58
+ function snapshotBatch(params, defaults, config) {
59
+ const hasDefaults = Object.keys(defaults).length > 0;
60
+ let encoded;
61
+ try { encoded = JSON.stringify(hasDefaults ? {programs:params.programs,...defaults} : params.programs); }
62
+ catch { throw new Error("programs and defaults must be JSON-serializable; no programs ran"); }
63
+ if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget (including shared code/file/data); no programs ran");
64
+ const parsed = hasDefaults ? JSON.parse(encoded) : {programs:JSON.parse(encoded)};
65
+ if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
66
+ return parsed;
67
+ }
68
+ export { parseBatchPayload, batchTimeoutMs };
@@ -0,0 +1,281 @@
1
+ import {AsyncLocalStorage} from 'node:async_hooks';
2
+ import {isString,isObject,looksLikePath} from '../shared/decode.js';
3
+ import {truncateChars} from '../output/format.js';
4
+ import {gatherReadArgs,normalizeRead,decodeReadValue,assertReadPaths} from '../contract/read.js';
5
+ import {classifyEdit} from '../contract/edit.js';
6
+ import {normalizeBash} from '../contract/bash.js';
7
+
8
+ const PER_PATH_HINT = "; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes";
9
+
10
+ function unwrapValue(res) {
11
+ if (res?.ok === false) throw new Error(String(res.value ?? res.error ?? "host tool failed"));
12
+
13
+ if ("value" in Object(res)) return res.value;
14
+
15
+ return res;
16
+ }
17
+
18
+ function unwrapRead(res, args) {
19
+ const value = unwrapValue(res);
20
+
21
+ if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
22
+
23
+ return decodeReadItem(args, value, res);
24
+ }
25
+
26
+ function decodeReadItem(args, value, res) {
27
+ if (!res?.typed) return decodeReadValue(args, value);
28
+ // JSON selectors used to cross RPC as separately encoded JSON values. Keep
29
+ // their mutable results independent, but put the copies in the guest heap.
30
+ return res.cloneItems ? value.map(item => structuredClone(item)) : value;
31
+ }
32
+
33
+ /** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
34
+ function leanEnvelope(res) {
35
+ if (!isObject(res)) return res;
36
+
37
+ if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
38
+
39
+ for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
40
+
41
+ if (res.truncated === false) delete res.truncated;
42
+
43
+ return res;
44
+ }
45
+
46
+ function swallow(promise) {
47
+ promise.catch(() => {});
48
+
49
+ return promise;
50
+ }
51
+
52
+ function throwReadPathError(target, detail) {
53
+ throw new Error(`read failed for ${target}: ${detail}${PER_PATH_HINT}`);
54
+ }
55
+
56
+ function missingResolvedIndex(values, paths) {
57
+ return values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(paths[index]));
58
+ }
59
+
60
+ function settleReadItem(wave, index, res) {
61
+ const job = wave[index];
62
+ if (!job) throw new Error("invalid streamed read index: " + index);
63
+ wave[index] = null; // Release resolver closures and their potentially large values.
64
+ try { job.resolve(unwrapRead(res,job.args)); }
65
+ catch (error) { job.reject(error); }
66
+ }
67
+
68
+ function settleInlineWave(wave,res,received) {
69
+ unwrapValue(res);
70
+ if (received || !Array.isArray(res.items) || res.items.length !== wave.length) throw new Error("invalid batch read response");
71
+ for (let i=0;i<wave.length;i++) {
72
+ const error = res.itemErrors?.[i];
73
+ settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],items:undefined});
74
+ }
75
+ }
76
+
77
+ async function rpcReadWave(rpc, wave) {
78
+ if (wave.length === 1) {
79
+ const res = leanEnvelope(await rpc("call",["read",wave[0].args]));
80
+ settleReadItem(wave,0,res);
81
+ return;
82
+ }
83
+ const args = {...wave[0].args,path:wave.map(job=>job.args.path),_independent:true};
84
+ let received = 0, last;
85
+ const onItem = (index,res) => {
86
+ if (!Number.isInteger(index) || !wave[index] || last?.index === index) throw new Error("invalid streamed read response");
87
+ received++;
88
+ // Keep the final read pending until the host RPC/barrier itself has settled.
89
+ // An awaited batch must never look complete while its host call is running.
90
+ if (received === wave.length) last = {index,res};
91
+ else settleReadItem(wave,index,res);
92
+ };
93
+ const res = leanEnvelope(await rpc("call",["read",args],onItem));
94
+ if (res?.streamed) {
95
+ if (received !== wave.length || !last) throw new Error("incomplete streamed read response");
96
+ settleReadItem(wave,last.index,last.res);
97
+ return;
98
+ }
99
+ settleInlineWave(wave,res,received);
100
+ }
101
+
102
+ function rejectReadWave(wave, error) {
103
+ for (const job of wave) job?.reject(error);
104
+ }
105
+
106
+ function dispatchReadWaves(pending, pendingReadWaves, rpc) {
107
+ for (let start=0;start<pending.length;start+=64) {
108
+ const wave = pending.slice(start,start+64);
109
+ const delivery = rpcReadWave(rpc,wave).catch(error=>rejectReadWave(wave,error));
110
+ pendingReadWaves.add(delivery);
111
+ void delivery.finally(()=>pendingReadWaves.delete(delivery));
112
+ }
113
+ }
114
+
115
+ function enqueueCompatibleRead(readState, flushReads, args) {
116
+ const key = JSON.stringify({ ...args, path: undefined });
117
+
118
+ if (readState.queued.length && readState.queued[0].key !== key) flushReads();
119
+
120
+ const promise = new Promise((resolve, reject) => {
121
+ readState.queued.push({ args, key, resolve, reject });
122
+
123
+ if (readState.queued.length === 1) queueMicrotask(flushReads);
124
+ });
125
+
126
+ return swallow(promise);
127
+ }
128
+
129
+ async function readManyPaths(readOne, args, paths) {
130
+ assertReadPaths(paths);
131
+ const values = await Promise.all(paths.map(async item => {
132
+ try { return await readOne({...args,path:item}); }
133
+ catch (error) { throwReadPathError(item,error.message); }
134
+ }));
135
+ const missing = args.resolve ? missingResolvedIndex(values,paths) : -1;
136
+ if (missing >= 0) throwReadPathError(paths[missing],"not_found");
137
+ return values;
138
+ }
139
+
140
+ async function runSpeculation(fn, token, checkpointScope, drainReads, rpc) {
141
+ let began = false;
142
+
143
+ try {
144
+ await drainReads();
145
+ await rpc("speculateBegin", []);
146
+ began = true;
147
+ const value = await checkpointScope.run(token, fn);
148
+ await drainReads();
149
+ await rpc("speculateCommit", []);
150
+
151
+ return { ok: true, committed: true, value };
152
+ } catch (err) {
153
+ await drainReads();
154
+
155
+ if (began) await rpc("speculateRollback", []);
156
+
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ function formatBashFailure(command, res) {
162
+ let exitCode;
163
+
164
+ try {
165
+ exitCode = JSON.parse(res.details).exitCode;
166
+ } catch {}
167
+
168
+ const output = String(res.value).trimEnd();
169
+ const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
170
+
171
+ return "command failed" + suffix + ": " + truncateChars(command, 240, "command").text + (output ? "\n" + output : "");
172
+ }
173
+
174
+ function markTruncatedOutput(res, text) {
175
+ if (res?.truncated && isString(text) && !text.includes("truncated")) return text + "\n…[output truncated]…";
176
+
177
+ return text;
178
+ }
179
+
180
+ export function buildGuestApi(rpc, batchRead) {
181
+ const checkpointScope = new AsyncLocalStorage();
182
+ let checkpoint = null;
183
+
184
+ const assertScope = () => {
185
+ const token = checkpointScope.getStore();
186
+
187
+ if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
188
+ };
189
+
190
+ // Post calls in submission order. The host alone owns file concurrency and
191
+ // read/shell/checkpoint barriers; a second guest queue would serialize edits.
192
+ async function checkpointEdit(fn) {
193
+ if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
194
+ const token = {};
195
+ checkpoint = token;
196
+ try { return await runSpeculation(fn, token, checkpointScope, drainReads, rpc); }
197
+ finally { checkpoint = null; }
198
+ }
199
+
200
+ // Coalesce already-started compatible reads without rewriting JS control flow.
201
+ const readState = { queued: [], waves: new Set() };
202
+
203
+ function flushReads() {
204
+ const pending = readState.queued;
205
+ readState.queued = [];
206
+ dispatchReadWaves(pending, readState.waves, rpc);
207
+ }
208
+
209
+ async function drainReads() {
210
+ for (;;) {
211
+ flushReads();
212
+ const waves = [...readState.waves];
213
+
214
+ if (!waves.length) return;
215
+ await Promise.allSettled(waves);
216
+ }
217
+ }
218
+
219
+ const invoke = (name, args) => {
220
+ assertScope(); flushReads();
221
+
222
+ return swallow(rpc("call", [name, args]).then(leanEnvelope));
223
+ };
224
+
225
+ const read = async (p, a, b) => {
226
+ assertScope();
227
+ const args = normalizeRead(gatherReadArgs(p, a, b));
228
+ p = args.path;
229
+
230
+ if (Array.isArray(p)) {
231
+ const values = await readManyPaths(read, args, p);
232
+ for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
233
+
234
+ return values;
235
+ }
236
+
237
+ const readValue = !batchRead
238
+ ? unwrapRead(await invoke("read", args), args)
239
+ : await enqueueCompatibleRead(readState, flushReads, args);
240
+ noteReadPath(args, readValue);
241
+
242
+ return readValue;
243
+ };
244
+
245
+ const readFiles = new Set();
246
+
247
+ function noteReadPath(args, value) {
248
+ if (isString(args.path) && looksLikePath(args.path)) readFiles.add(args.path);
249
+ if (isObject(value) && isString(value.path) && looksLikePath(value.path)) readFiles.add(value.path);
250
+ }
251
+
252
+ const write = async (p, content) => {
253
+ const args = isObject(p) ? p : { path: p, content };
254
+
255
+ if (args.append !== true && args.replace !== true && isString(args.path) && readFiles.has(args.path)) {
256
+ throw new Error("file was already read this program; use edit(oldText, newText) or edit(view, ...). write({path,content,replace:true}) replaces anyway");
257
+ }
258
+
259
+ return unwrapValue(await invoke("write", args));
260
+ };
261
+
262
+ const edit = async (p, oldText, newText) => {
263
+ const classified = classifyEdit(p, oldText, newText);
264
+
265
+ if (classified.kind === "checkpoint") return checkpointEdit(classified.fn);
266
+
267
+ return unwrapValue(await invoke(classified.command, classified.args));
268
+ };
269
+
270
+ const bash = async (command, opts) => {
271
+ const args = normalizeBash(command, opts);
272
+ command = args.command;
273
+ const res = await invoke("bash", args);
274
+
275
+ if (res?.ok === false) throw new Error(formatBashFailure(command, res));
276
+
277
+ return markTruncatedOutput(res, unwrapValue(res));
278
+ };
279
+
280
+ return { read, write, edit, bash };
281
+ }