pi-supernova 0.0.6 → 0.0.8

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.
@@ -0,0 +1,344 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { parallel as runParallel, pipeline as runPipeline } from "./parallel.js";
3
+ import { isString, isFunction, isObject } from "./decode.js";
4
+
5
+ // Guest programs run here, off the host thread. The host can terminate() this
6
+ // worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
7
+ // code cannot take the harness down with it.
8
+
9
+ const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
10
+ const compiledCache = new Map();
11
+ const COMPILED_CACHE_MAX = 256;
12
+ const PARAMS = [
13
+ "nova", "tools", "console", "parallel", "pipeline",
14
+ "read", "write", "edit", "patch", "surface", "snap", "bash", "exec", "speculate",
15
+ ];
16
+ // V8 and JSC both place the body on the line after the synthesized header.
17
+ const BODY_LINE_OFFSET = 2;
18
+
19
+ function skipLeadingComments(src) {
20
+ let i = 0;
21
+ for (;;) {
22
+ while (/\s/.test(src[i])) i++;
23
+ if (src.startsWith("//", i)) {
24
+ const nl = src.indexOf("\n", i);
25
+ if (nl < 0) return src.length;
26
+ i = nl + 1;
27
+ } else if (src.startsWith("/*", i)) {
28
+ const end = src.indexOf("*/", i + 2);
29
+ if (end < 0) return src.length;
30
+ i = end + 2;
31
+ } else {
32
+ return i;
33
+ }
34
+ }
35
+ }
36
+
37
+ function skipString(src, j, quote) {
38
+ for (j++; j < src.length && src[j] !== quote; j++) if (src[j] === "\\") j++;
39
+ return j;
40
+ }
41
+
42
+ function skipBalancedParens(src, i) {
43
+ let depth = 0;
44
+ for (let j = i; j < src.length; j++) {
45
+ const ch = src[j];
46
+ if (ch === "(") {
47
+ depth++;
48
+ continue;
49
+ }
50
+ if (ch === ")") {
51
+ depth--;
52
+ if (depth === 0) return j + 1;
53
+ continue;
54
+ }
55
+ if ("\"'\u0060".includes(ch)) j = skipString(src, j, ch);
56
+ }
57
+ return -1;
58
+ }
59
+
60
+ /** True when src (after comments) is a single arrow or function expression. */
61
+ function isFunctionExpression(src) {
62
+ let i = skipLeadingComments(src);
63
+ const rest = src.slice(i);
64
+ if (/^(async\s+)?function\b/.test(rest)) return true;
65
+ const asyncMatch = /^async\s+/.exec(rest);
66
+ if (asyncMatch) i += asyncMatch[0].length;
67
+ if (/^[A-Za-z_$][\w$]*\s*=>/.test(src.slice(i))) return true;
68
+ if (src[i] !== "(") return false;
69
+ const after = skipBalancedParens(src, i);
70
+ if (after < 0) return false;
71
+ return /^\s*=>/.test(src.slice(after));
72
+ }
73
+
74
+ function wrapBody(code) {
75
+ const trimmed = String(code).trim();
76
+ if (!trimmed) throw new Error("code must be a non-empty string");
77
+ if (isFunctionExpression(trimmed)) return "const __fn = (" + trimmed + ");\nreturn await __fn();";
78
+ return trimmed;
79
+ }
80
+
81
+ function compile(code) {
82
+ const body = wrapBody(code);
83
+ let compiled = compiledCache.get(body);
84
+ if (compiled) return compiled;
85
+ compiled = new AsyncFunction(...PARAMS, body);
86
+ if (compiledCache.size >= COMPILED_CACHE_MAX) {
87
+ compiledCache.delete(compiledCache.keys().next().value);
88
+ }
89
+ compiledCache.set(body, compiled);
90
+ return compiled;
91
+ }
92
+
93
+ /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
94
+ function guestLocation(err) {
95
+ const stack = String(err?.stack);
96
+ const m = /(?:<anonymous>:|(?:eval code|anonymous)(?:@|:))(\d+):(\d+)/.exec(stack);
97
+ if (!m) return null;
98
+ const line = Number(m[1]) - BODY_LINE_OFFSET;
99
+ if (line < 1) return null;
100
+ return { line, col: Number(m[2]) };
101
+ }
102
+
103
+ const MAX_DEPTH = 64;
104
+ const MAX_TYPED_ARRAY = 4096;
105
+
106
+ function plainFromBinary(value) {
107
+ const bytes = value.byteLength;
108
+ if (value instanceof ArrayBuffer) value = new Uint8Array(value);
109
+ if (value.length > MAX_TYPED_ARRAY) return "[" + value.constructor.name + " " + bytes + " bytes]";
110
+ return Array.from(value, (x) => (typeof x === "bigint" ? x.toString() + "n" : x));
111
+ }
112
+
113
+ function plainFromMap(value, seen, depth) {
114
+ const allStringKeys = [...value.keys()].every((k) => typeof k === "string");
115
+ if (!allStringKeys) return [...value].map(([k, v]) => [toPlain(k, seen, depth + 1), toPlain(v, seen, depth + 1)]);
116
+ const out = {};
117
+ for (const [k, v] of value) out[k] = toPlain(v, seen, depth + 1);
118
+ return out;
119
+ }
120
+
121
+ function plainFromCollection(value, seen, depth) {
122
+ if (Array.isArray(value)) return value.map((x) => toPlain(x, seen, depth + 1));
123
+ if (value instanceof Set) return [...value].map((x) => toPlain(x, seen, depth + 1));
124
+ if (value instanceof Map) return plainFromMap(value, seen, depth);
125
+ const out = {};
126
+ for (const k of Object.keys(value)) out[k] = toPlain(value[k], seen, depth + 1);
127
+ return out;
128
+ }
129
+
130
+ /** Convert any guest value to structured-clone-safe, JSON-shaped data. */
131
+ function toPlain(value, seen = new Set(), depth = 0) {
132
+ if (value === null || value === undefined) return value;
133
+ const t = typeof value;
134
+ if (t === "string" || t === "number" || t === "boolean") return value;
135
+ if (t === "bigint") return value.toString() + "n";
136
+ if (t === "function") return "[Function" + (value.name ? " " + value.name : "") + "]";
137
+ if (t === "symbol") return value.toString();
138
+ if (depth > MAX_DEPTH) return "[Depth]";
139
+ if (seen.has(value)) return "[Circular]";
140
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
141
+ if (value instanceof RegExp) return value.toString();
142
+ if (value instanceof Error) {
143
+ const out = { name: value.name, message: value.message };
144
+ if (value.cause !== undefined) out.cause = toPlain(value.cause, seen, depth + 1);
145
+ return out;
146
+ }
147
+ if (value instanceof Promise) return "[Promise]";
148
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return plainFromBinary(value);
149
+ if (isFunction(value.toJSON)) return toPlain(value.toJSON(), seen, depth + 1);
150
+ seen.add(value);
151
+ try {
152
+ return plainFromCollection(value, seen, depth);
153
+ } finally {
154
+ seen.delete(value);
155
+ }
156
+ }
157
+
158
+ // ---- RPC to the host thread ----
159
+
160
+ let activeRunId = 0;
161
+ let rpcSeq = 0;
162
+ const pendingRpc = new Map();
163
+
164
+ function post(msg) {
165
+ parentPort.postMessage(msg);
166
+ }
167
+
168
+ function rpc(method, args) {
169
+ return new Promise((resolve, reject) => {
170
+ const id = ++rpcSeq;
171
+ pendingRpc.set(id, { resolve, reject });
172
+ try {
173
+ post({ op: "rpc", id, runId: activeRunId, method, args });
174
+ } catch (err) {
175
+ pendingRpc.delete(id);
176
+ reject(new Error("nova." + method + " arguments are not transferable: " + err?.message));
177
+ }
178
+ });
179
+ }
180
+
181
+ function unwrapValue(res) {
182
+ if ("value" in Object(res)) return res.value;
183
+ return res;
184
+ }
185
+
186
+ function unwrapJsonValue(res) {
187
+ const value = unwrapValue(res);
188
+ try {
189
+ return JSON.parse(value);
190
+ } catch {
191
+ return value;
192
+ }
193
+ }
194
+
195
+ /** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
196
+ function leanEnvelope(res) {
197
+ if (!isObject(res)) return res;
198
+ if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
199
+ for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
200
+ if (res.truncated === false) delete res.truncated;
201
+ return res;
202
+ }
203
+
204
+ function buildGuestApi(available) {
205
+ const availableSet = new Set(available);
206
+ const nova = {
207
+ search: (query, limit) => rpc("search", [query, limit]),
208
+ describe: (name) => rpc("describe", [name]),
209
+ call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
210
+ async callMany(calls) {
211
+ const wave = await rpc("callMany", [calls]);
212
+ const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
213
+ Object.defineProperties(results, {
214
+ mode: { value: wave?.mode, enumerable: false },
215
+ reason: { value: wave?.reason, enumerable: false },
216
+ results: { value: results, enumerable: false },
217
+ });
218
+ return results;
219
+ },
220
+ async speculate(fn) {
221
+ await rpc("speculateBegin", []);
222
+ try {
223
+ const value = await fn();
224
+ await rpc("speculateCommit", []);
225
+ return { ok: true, committed: true, value };
226
+ } catch (err) {
227
+ await rpc("speculateRollback", []);
228
+ return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
229
+ }
230
+ },
231
+ surface: async (filePath) => unwrapJsonValue(await rpc("surface", [filePath])),
232
+ snap: async (query, targetPath) => unwrapJsonValue(await rpc("snap", [query, targetPath])),
233
+ has: (name) => availableSet.has(name),
234
+ };
235
+
236
+ const read = async (p, offset, limit) => {
237
+ if (Array.isArray(p)) {
238
+ const res = await nova.call("read", { path: p, offset, limit });
239
+ if (Array.isArray(res?.items)) return res.items;
240
+ // Captured host executor without batch support: fan out.
241
+ return Promise.all(p.map((item) => read(item, offset, limit)));
242
+ }
243
+ return unwrapValue(await nova.call("read", { path: p, offset, limit }));
244
+ };
245
+ const write = async (p, content) => unwrapValue(await nova.call("write", { path: p, content }));
246
+ const edit = async (p, oldText, newText) => unwrapValue(await nova.call("edit", { path: p, oldText, newText }));
247
+ const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
248
+ const bash = async (command, opts) => {
249
+ const res = await nova.call("bash", { command, ...opts });
250
+ if (res?.ok === false) {
251
+ let exitCode;
252
+ try {
253
+ exitCode = JSON.parse(res.details).exitCode;
254
+ } catch {}
255
+ const output = String(res.value).trimEnd();
256
+ const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
257
+ throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
258
+ }
259
+ let text = unwrapValue(res);
260
+ if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
261
+ return text;
262
+ };
263
+ const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
264
+ const exec = async (cmd, args, opts) => {
265
+ const command = String(cmd ?? "").trim();
266
+ if (!command) throw new Error("exec requires command");
267
+ // exec("git status") is a shell line; exec("git", ["status"]) is argv.
268
+ if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
269
+ return bash([command, ...args].map(quoteShellArg).join(" "), opts);
270
+ };
271
+
272
+ return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, bash, exec, speculate: nova.speculate };
273
+ }
274
+
275
+ function makeConsole(runId, limits) {
276
+ let count = 0;
277
+ const emit = (...args) => {
278
+ if (count >= limits.maxLogLines) return;
279
+ count++;
280
+ const line = args
281
+ .map((a) => {
282
+ if (isString(a)) return a;
283
+ try {
284
+ return JSON.stringify(toPlain(a));
285
+ } catch {
286
+ return String(a);
287
+ }
288
+ })
289
+ .join(" ");
290
+ post({ op: "log", runId, line: line.length > limits.maxLogLineChars ? line.slice(0, limits.maxLogLineChars) + "…" : line });
291
+ };
292
+ return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
293
+ }
294
+
295
+ function postFailure(runId, err, location) {
296
+ const message = err instanceof Error ? err.message : String(err);
297
+ post({ op: "error", runId, message, location });
298
+ }
299
+
300
+ async function handleRun(msg) {
301
+ const { runId, code, limits, available } = msg;
302
+ activeRunId = runId;
303
+ let compiled;
304
+ try {
305
+ compiled = compile(code);
306
+ } catch (err) {
307
+ postFailure(runId, err);
308
+ return;
309
+ }
310
+ const api = buildGuestApi(available);
311
+ const scopedConsole = makeConsole(runId, limits);
312
+ try {
313
+ const value = await compiled(
314
+ api.nova, api.nova, scopedConsole, runParallel, runPipeline,
315
+ api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.bash, api.exec, api.speculate,
316
+ );
317
+ if (runId !== activeRunId) return;
318
+ let plain;
319
+ try {
320
+ plain = toPlain(value);
321
+ } catch (err) {
322
+ plain = "[unserializable: " + (err?.message || err) + "]";
323
+ }
324
+ post({ op: "done", runId, value: plain, undefinedReturn: value === undefined, hasReturn: /\breturn\b/.test(code) });
325
+ } catch (err) {
326
+ if (runId !== activeRunId) return;
327
+ postFailure(runId, err, guestLocation(err));
328
+ }
329
+ }
330
+
331
+ parentPort.on("message", (msg) => {
332
+ if (!isObject(msg)) return;
333
+ if (msg.op === "rpc:result") {
334
+ const pending = pendingRpc.get(msg.id);
335
+ if (!pending) return;
336
+ pendingRpc.delete(msg.id);
337
+ if (msg.ok) pending.resolve(msg.value);
338
+ else pending.reject(new Error(msg.error));
339
+ return;
340
+ }
341
+ if (msg.op === "run") void handleRun(msg);
342
+ });
343
+
344
+ post({ op: "ready" });