pi-supernova 0.0.15 → 0.2.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/decode.js CHANGED
@@ -4,3 +4,64 @@ export const isString = (v) => toStr.call(v) === "[object String]";
4
4
  export const isObject = (v) => toStr.call(v) === "[object Object]";
5
5
  export const isFunction = (v) => toStr.call(v) === "[object Function]" || v instanceof Function;
6
6
  export const isNumber = (v) => toStr.call(v) === "[object Number]" && Number.isFinite(v);
7
+
8
+ const MAX_DEPTH = 64;
9
+ const MAX_TYPED_ARRAY = 4096;
10
+
11
+ function plainFromBinary(value) {
12
+ const bytes = value.byteLength;
13
+ if (value instanceof ArrayBuffer) value = new Uint8Array(value);
14
+ else if (value instanceof DataView) value = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
15
+ if (value.length > MAX_TYPED_ARRAY) return "[" + value.constructor.name + " " + bytes + " bytes]";
16
+ return value instanceof BigInt64Array || value instanceof BigUint64Array
17
+ ? Array.from(value, x => x.toString() + "n")
18
+ : Array.from(value);
19
+ }
20
+
21
+ function plainFromMap(value, seen, depth) {
22
+ const allStringKeys = [...value.keys()].every(isString);
23
+ if (!allStringKeys) return [...value].map(([k, v]) => [toPlain(k, seen, depth + 1), toPlain(v, seen, depth + 1)]);
24
+ const out = Object.create(null);
25
+ for (const [k, v] of value) out[k] = toPlain(v, seen, depth + 1);
26
+ return out;
27
+ }
28
+
29
+ function plainFromCollection(value, seen, depth) {
30
+ if (Array.isArray(value)) return value.map((x) => toPlain(x, seen, depth + 1));
31
+ if (value instanceof Set) return [...value].map((x) => toPlain(x, seen, depth + 1));
32
+ if (value instanceof Map) return plainFromMap(value, seen, depth);
33
+ const out = Object.create(null);
34
+ for (const k of Object.keys(value)) out[k] = toPlain(value[k], seen, depth + 1);
35
+ return out;
36
+ }
37
+
38
+ /** Convert any guest value to structured-clone-safe, JSON-shaped data. */
39
+ export function toPlain(value, seen = new Set(), depth = 0) {
40
+ if (value === null || value === undefined) return value;
41
+ const tag = toStr.call(value);
42
+ if (tag === "[object String]" || tag === "[object Number]" || tag === "[object Boolean]") return value.valueOf();
43
+ if (tag === "[object BigInt]") return value.toString() + "n";
44
+ if (isFunction(value)) return "[Function" + (value.name ? " " + value.name : "") + "]";
45
+ if (tag === "[object Symbol]") return value.toString();
46
+ if (depth > MAX_DEPTH) return "[Depth]";
47
+ if (seen.has(value)) return "[Circular]";
48
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
49
+ if (value instanceof RegExp) return value.toString();
50
+ if (value instanceof Error) {
51
+ const out = { name: value.name, message: value.message };
52
+ if (value.cause !== undefined) out.cause = toPlain(value.cause, seen, depth + 1);
53
+ return out;
54
+ }
55
+ if (value instanceof Promise) return "[Promise]";
56
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return plainFromBinary(value);
57
+ if (isFunction(value.toJSON)) return toPlain(value.toJSON(), seen, depth + 1);
58
+ seen.add(value);
59
+ try {
60
+ return plainFromCollection(value, seen, depth);
61
+ } finally {
62
+ seen.delete(value);
63
+ }
64
+ }
65
+
66
+ // ---- RPC to the host thread ----
67
+
package/evidence.js CHANGED
@@ -309,7 +309,7 @@ function normalize(scores) {
309
309
 
310
310
  // ---- candidate files (boundary + topology + entity hits) ----
311
311
 
312
- function candidateFiles(files, profile, index, limit) {
312
+ function candidateFiles(files, profile, index, limit, overlayText) {
313
313
  const scored = [];
314
314
  for (const f of files) {
315
315
  const s = scorePathTopology(f, profile.keywords, profile.flags);
@@ -318,7 +318,11 @@ function candidateFiles(files, profile, index, limit) {
318
318
  scored.sort((a, b) => b.s - a.s);
319
319
  const chosen = new Set(scored.slice(0, limit).map(({ f }) => f));
320
320
  const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
321
- const hits = anchors.length ? index.filesContaining(files, anchors, true) : [];
321
+ const pendingHits = files.filter(file => {
322
+ const pending = overlayText(file);
323
+ return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
324
+ });
325
+ const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
322
326
  for (const f of hits) {
323
327
  if (chosen.size >= limit) break;
324
328
  if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
@@ -385,14 +389,19 @@ function render(spans, picks, fused, opts, root) {
385
389
  * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
386
390
  * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
387
391
  */
388
- export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, options = {} }) {
392
+ export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
389
393
  const opts = { ...EVIDENCE_DEFAULTS, ...options };
390
394
  const profile = profileQuery(query);
391
395
  if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
392
- const files = await index.files(searchDir || root);
396
+ const searchRoot = path.resolve(searchDir || root);
397
+ const staged = pendingPaths.filter(file => {
398
+ const relative = path.relative(searchRoot, file);
399
+ return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
400
+ });
401
+ const files = [...new Set([...await index.files(searchRoot), ...staged])];
393
402
  if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
394
403
 
395
- const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles);
404
+ const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
396
405
  const spans = [];
397
406
  for (const f of chosenFiles) {
398
407
  const pending = overlayText(f);
package/format.js CHANGED
@@ -1,4 +1,4 @@
1
- import { isString } from "./decode.js";
1
+ import { isString, isObject } from "./decode.js";
2
2
 
3
3
  export function truncateChars(text, maxChars, label = "value") {
4
4
  const normalized = isString(text) ? text : String(text ?? "");
@@ -12,22 +12,21 @@ export function truncateChars(text, maxChars, label = "value") {
12
12
  originalChars: normalized.length,
13
13
  };
14
14
  }
15
- const head = headEnd(normalized, Math.floor(limit * 0.7));
16
- let tail = Math.max(0, limit - head);
15
+ let head = headEnd(normalized, Math.floor(limit * 0.7));
16
+ let tail = 0;
17
17
  let marker = "";
18
- let previousTail = -1;
19
- while (tail !== previousTail) {
20
- previousTail = tail;
18
+ for (;;) {
21
19
  const omitted = normalized.length - head - tail;
22
- marker = `\n…[${label} truncated ${omitted} chars]…\n`;
23
- tail = Math.max(0, limit - head - marker.length);
20
+ marker = "\n…[" + label + " truncated " + omitted + " chars]…\n";
21
+ if (marker.length > limit) return { text: normalized.slice(0, headEnd(normalized, limit)), truncated: true, originalChars: normalized.length };
22
+ const budget = limit - marker.length;
23
+ const nextHead = headEnd(normalized, Math.min(head, budget));
24
+ const nextTail = normalized.length - tailStartIndex(normalized, Math.max(0, budget - nextHead));
25
+ if (nextHead === head && nextTail === tail) break;
26
+ head = nextHead;
27
+ tail = nextTail;
24
28
  }
25
- const tailStart = tailStartIndex(normalized, tail);
26
- return {
27
- text: normalized.slice(0, head) + marker + normalized.slice(tailStart),
28
- truncated: true,
29
- originalChars: normalized.length,
30
- };
29
+ return { text: normalized.slice(0, head) + marker + normalized.slice(normalized.length - tail), truncated: true, originalChars: normalized.length };
31
30
  }
32
31
 
33
32
  // Lone surrogates in a tool result make the message invalid UTF-8 at the API
@@ -53,7 +52,7 @@ function formatKey(key) {
53
52
 
54
53
  function formatPrimitive(value) {
55
54
  if (value === undefined) return "undefined";
56
- if (typeof value === "number" && !Number.isFinite(value)) return String(value);
55
+ if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
57
56
  return JSON.stringify(value) ?? String(value);
58
57
  }
59
58
 
@@ -65,7 +64,7 @@ function formatFlatList(value) {
65
64
  }
66
65
 
67
66
  function formatFlat(value) {
68
- if (value === null || typeof value !== "object") return formatPrimitive(value);
67
+ if ((!isObject(value) && !Array.isArray(value))) return formatPrimitive(value);
69
68
  if (Array.isArray(value)) return formatFlatList(value);
70
69
  let out = "";
71
70
  for (const key of Object.keys(value)) {
@@ -83,7 +82,7 @@ function formatFlat(value) {
83
82
  */
84
83
  export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
85
84
  const flat = formatFlat(value);
86
- if (value === null || typeof value !== "object" || flat.length + indent.length <= width) return flat;
85
+ if ((!isObject(value) && !Array.isArray(value)) || flat.length + indent.length <= width) return flat;
87
86
  const pad = indent + " ";
88
87
  if (Array.isArray(value)) {
89
88
  if (value.length === 0) return "[]";
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", "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]),
@@ -238,7 +105,9 @@ function buildGuestApi(available) {
238
105
  const readArgs = (p, a, b) => (isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
239
106
  const read = async (p, a, b) => {
240
107
  if (Array.isArray(p)) {
108
+ if (!batchRead) return Promise.all(p.map(item => read(item, a, b)));
241
109
  const res = await nova.call("read", readArgs(p, a, b));
110
+ unwrapValue(res);
242
111
  if (Array.isArray(res?.items)) return res.items;
243
112
  // Captured host executor without batch support: fan out.
244
113
  return Promise.all(p.map((item) => read(item, a, b)));
@@ -277,8 +146,13 @@ function buildGuestApi(available) {
277
146
 
278
147
  function makeConsole(runId, limits) {
279
148
  let count = 0;
149
+ let truncated = false;
150
+ const markTruncated = () => {
151
+ if (!truncated) post({ op: "logTruncated", runId });
152
+ truncated = true;
153
+ };
280
154
  const emit = (...args) => {
281
- if (count >= limits.maxLogLines) return;
155
+ if (count >= limits.maxLogLines) { markTruncated(); return; }
282
156
  count++;
283
157
  const line = args
284
158
  .map((a) => {
@@ -290,30 +164,34 @@ function makeConsole(runId, limits) {
290
164
  }
291
165
  })
292
166
  .join(" ");
293
- 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 });
294
170
  };
295
171
  return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
296
172
  }
297
173
 
298
174
  function postFailure(runId, err, location) {
175
+ runActive = false;
299
176
  const message = err instanceof Error ? err.message : String(err);
300
177
  post({ op: "error", runId, message, location });
301
178
  }
302
179
 
303
180
  async function handleRun(msg) {
304
- const { runId, code, limits, available } = msg;
181
+ const { runId, prepared, limits, available, batchRead = true } = msg;
305
182
  activeRunId = runId;
183
+ runActive = true;
306
184
  let compiled;
307
185
  try {
308
- compiled = compile(code);
186
+ compiled = { fn: new AsyncFunction(...PARAMS, prepared.body), hasReturn: prepared.hasReturn };
309
187
  } catch (err) {
310
188
  postFailure(runId, err);
311
189
  return;
312
190
  }
313
- const api = buildGuestApi(available);
191
+ const api = buildGuestApi(available, batchRead, runId);
314
192
  const scopedConsole = makeConsole(runId, limits);
315
193
  try {
316
- const value = await compiled(
194
+ const value = await compiled.fn(
317
195
  api.nova, scopedConsole, runParallel, runPipeline,
318
196
  api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
319
197
  );
@@ -324,7 +202,8 @@ async function handleRun(msg) {
324
202
  } catch (err) {
325
203
  plain = "[unserializable: " + (err?.message || err) + "]";
326
204
  }
327
- 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 });
328
207
  } catch (err) {
329
208
  if (runId !== activeRunId) return;
330
209
  postFailure(runId, err, guestLocation(err));