pi-supernova 0.0.11 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/guest-worker.js CHANGED
@@ -1,95 +1,15 @@
1
1
  import { parentPort } from "node:worker_threads";
2
2
  import { parallel as runParallel, pipeline as runPipeline } from "./parallel.js";
3
- import { isString, isFunction, isObject } from "./decode.js";
3
+ import { isString, isObject, toPlain } from "./decode.js";
4
+ import { truncateChars } from "./format.js";
4
5
 
5
6
  // Guest programs run here, off the host thread. The host can terminate() this
6
7
  // worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
7
8
  // code cannot take the harness down with it.
8
9
 
9
10
  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", "evidence", "bash", "exec", "speculate",
15
- ];
16
- // V8 and JSC both place the body on the line after the synthesized header.
11
+ const PARAMS = ["nova", "console", "parallel", "pipeline", "read", "write", "edit", "patch", "surface", "snap", "evidence", "bash", "exec", "speculate"];
17
12
  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
13
  /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
94
14
  function guestLocation(err) {
95
15
  const stack = String(err?.stack);
@@ -100,64 +20,8 @@ function guestLocation(err) {
100
20
  return { line, col: Number(m[2]) };
101
21
  }
102
22
 
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
23
  let activeRunId = 0;
24
+ let runActive = false;
161
25
  let rpcSeq = 0;
162
26
  const pendingRpc = new Map();
163
27
 
@@ -165,12 +29,13 @@ function post(msg) {
165
29
  parentPort.postMessage(msg);
166
30
  }
167
31
 
168
- function rpc(method, args) {
32
+ function callRpc(runId, method, args) {
33
+ if (!runActive || runId !== activeRunId) return Promise.reject(new Error("program is already complete"));
169
34
  return new Promise((resolve, reject) => {
170
35
  const id = ++rpcSeq;
171
36
  pendingRpc.set(id, { resolve, reject });
172
37
  try {
173
- post({ op: "rpc", id, runId: activeRunId, method, args });
38
+ post({ op: "rpc", id, runId, method, args });
174
39
  } catch (err) {
175
40
  pendingRpc.delete(id);
176
41
  reject(new Error("nova." + method + " arguments are not transferable: " + err?.message));
@@ -179,6 +44,7 @@ function rpc(method, args) {
179
44
  }
180
45
 
181
46
  function unwrapValue(res) {
47
+ if (res?.ok === false) throw new Error(String(res.value ?? res.error ?? "host tool failed"));
182
48
  if ("value" in Object(res)) return res.value;
183
49
  return res;
184
50
  }
@@ -201,7 +67,8 @@ function leanEnvelope(res) {
201
67
  return res;
202
68
  }
203
69
 
204
- function buildGuestApi(available) {
70
+ function buildGuestApi(available, batchRead, runId) {
71
+ const rpc = (method, args) => callRpc(runId, method, args);
205
72
  const availableSet = new Set(available);
206
73
  const nova = {
207
74
  search: (query, limit) => rpc("search", [query, limit]),
@@ -228,20 +95,24 @@ function buildGuestApi(available) {
228
95
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
229
96
  }
230
97
  },
231
- surface: async (filePath) => unwrapJsonValue(await rpc("surface", [filePath])),
98
+ surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
232
99
  evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
233
- snap: async (query, targetPath) => unwrapJsonValue(await rpc("snap", [query, targetPath])),
100
+ snap: async (query, targetPath) => unwrapJsonValue(await rpc("call", ["snap", { query, path: targetPath }])),
234
101
  has: (name) => availableSet.has(name),
235
102
  };
236
103
 
237
- const read = async (p, offset, limit) => {
104
+ // read(path, offset?, limit?) or read(path, { about, offset, limit, maxChars })
105
+ const readArgs = (p, a, b) => (isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
106
+ const read = async (p, a, b) => {
238
107
  if (Array.isArray(p)) {
239
- const res = await nova.call("read", { path: p, offset, limit });
108
+ if (!batchRead) return Promise.all(p.map(item => read(item, a, b)));
109
+ const res = await nova.call("read", readArgs(p, a, b));
110
+ unwrapValue(res);
240
111
  if (Array.isArray(res?.items)) return res.items;
241
112
  // Captured host executor without batch support: fan out.
242
- return Promise.all(p.map((item) => read(item, offset, limit)));
113
+ return Promise.all(p.map((item) => read(item, a, b)));
243
114
  }
244
- return unwrapValue(await nova.call("read", { path: p, offset, limit }));
115
+ return unwrapValue(await nova.call("read", readArgs(p, a, b)));
245
116
  };
246
117
  const write = async (p, content) => unwrapValue(await nova.call("write", { path: p, content }));
247
118
  const edit = async (p, oldText, newText) => unwrapValue(await nova.call("edit", { path: p, oldText, newText }));
@@ -275,8 +146,13 @@ function buildGuestApi(available) {
275
146
 
276
147
  function makeConsole(runId, limits) {
277
148
  let count = 0;
149
+ let truncated = false;
150
+ const markTruncated = () => {
151
+ if (!truncated) post({ op: "logTruncated", runId });
152
+ truncated = true;
153
+ };
278
154
  const emit = (...args) => {
279
- if (count >= limits.maxLogLines) return;
155
+ if (count >= limits.maxLogLines) { markTruncated(); return; }
280
156
  count++;
281
157
  const line = args
282
158
  .map((a) => {
@@ -288,31 +164,35 @@ function makeConsole(runId, limits) {
288
164
  }
289
165
  })
290
166
  .join(" ");
291
- post({ op: "log", runId, line: line.length > limits.maxLogLineChars ? line.slice(0, limits.maxLogLineChars) + "…" : line });
167
+ const clipped = truncateChars(line, limits.maxLogLineChars, "log");
168
+ if (clipped.truncated) markTruncated();
169
+ post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
292
170
  };
293
171
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
294
172
  }
295
173
 
296
174
  function postFailure(runId, err, location) {
175
+ runActive = false;
297
176
  const message = err instanceof Error ? err.message : String(err);
298
177
  post({ op: "error", runId, message, location });
299
178
  }
300
179
 
301
180
  async function handleRun(msg) {
302
- const { runId, code, limits, available } = msg;
181
+ const { runId, prepared, limits, available, batchRead = true } = msg;
303
182
  activeRunId = runId;
183
+ runActive = true;
304
184
  let compiled;
305
185
  try {
306
- compiled = compile(code);
186
+ compiled = { fn: new AsyncFunction(...PARAMS, prepared.body), hasReturn: prepared.hasReturn };
307
187
  } catch (err) {
308
188
  postFailure(runId, err);
309
189
  return;
310
190
  }
311
- const api = buildGuestApi(available);
191
+ const api = buildGuestApi(available, batchRead, runId);
312
192
  const scopedConsole = makeConsole(runId, limits);
313
193
  try {
314
- const value = await compiled(
315
- api.nova, api.nova, scopedConsole, runParallel, runPipeline,
194
+ const value = await compiled.fn(
195
+ api.nova, scopedConsole, runParallel, runPipeline,
316
196
  api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
317
197
  );
318
198
  if (runId !== activeRunId) return;
@@ -322,7 +202,8 @@ async function handleRun(msg) {
322
202
  } catch (err) {
323
203
  plain = "[unserializable: " + (err?.message || err) + "]";
324
204
  }
325
- post({ op: "done", runId, value: plain, undefinedReturn: value === undefined, hasReturn: /\breturn\b/.test(code) });
205
+ runActive = false;
206
+ post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
326
207
  } catch (err) {
327
208
  if (runId !== activeRunId) return;
328
209
  postFailure(runId, err, guestLocation(err));