pi-supernova 0.8.2 → 0.9.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.
Files changed (66) 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/query.js +71 -0
  21. package/src/context/repo-index.js +8 -162
  22. package/src/context/search-files.js +19 -0
  23. package/src/context/search.js +2 -24
  24. package/src/context/snap-search.js +202 -0
  25. package/src/context/snap.js +5 -266
  26. package/src/context/source-entry.js +112 -0
  27. package/src/contract/bash.js +6 -1
  28. package/src/contract/program.js +36 -0
  29. package/src/contract/read.js +8 -53
  30. package/src/fs/check.js +1 -1
  31. package/src/fs/commit.js +161 -0
  32. package/src/fs/diff.js +11 -15
  33. package/src/fs/directory.js +79 -0
  34. package/src/fs/file-io.js +100 -0
  35. package/src/fs/glob.js +54 -0
  36. package/src/fs/json-size.js +54 -0
  37. package/src/fs/lines.js +117 -0
  38. package/src/fs/read-window.js +74 -0
  39. package/src/fs/session-resource.js +50 -0
  40. package/src/fs/text-ops.js +7 -227
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +2 -1
  43. package/src/output/bottleneck.js +13 -67
  44. package/src/output/final.js +114 -0
  45. package/src/output/format.js +94 -5
  46. package/src/output/outcome.js +91 -0
  47. package/src/runtime/batch-input.js +68 -0
  48. package/src/runtime/guest-api.js +281 -0
  49. package/src/runtime/guest-worker.js +62 -333
  50. package/src/runtime/parallel.js +41 -39
  51. package/src/runtime/program-batch.js +21 -75
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +6 -5
  55. package/src/runtime/runtime.js +77 -253
  56. package/src/runtime/worker-pool.js +91 -0
  57. package/src/shared/decode.js +22 -8
  58. package/src/shared/image-worker.js +30 -0
  59. package/src/shared/image.js +78 -0
  60. package/src/shared/png.js +57 -0
  61. package/src/shared/result.js +77 -0
  62. package/src/shared/syntax-context.js +61 -3
  63. package/src/ui/host-render.js +104 -0
  64. package/src/ui/progress.js +51 -0
  65. package/src/ui/render.js +21 -421
  66. package/src/ui/trace.js +277 -0
@@ -0,0 +1,78 @@
1
+ import {createHash} from "node:crypto";
2
+ import childProcess from "node:child_process";
3
+ import {fileURLToPath} from "node:url";
4
+ import {assertModelImageMime,decodeImageData,errorMessage} from "./decode.js";
5
+ import {assertPng} from "./png.js";
6
+
7
+ const MAX_BYTES = 20 * 1024 * 1024;
8
+ // Cache only successful content digests, never image bytes or file paths. A file
9
+ // changed in place cannot reuse validation of its old contents.
10
+ const verified = new Set();
11
+ const workerPath = fileURLToPath(new URL("./image-worker.js",import.meta.url));
12
+ let tail = Promise.resolve();
13
+
14
+ function matchesSignature(bytes, mime) {
15
+ if (mime === "image/png") return bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]));
16
+ if (mime === "image/jpeg") return bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
17
+ if (mime === "image/gif") return ["GIF87a","GIF89a"].includes(bytes.toString("ascii",0,6));
18
+ return bytes.toString("ascii",0,4) === "RIFF" && bytes.toString("ascii",8,12) === "WEBP";
19
+ }
20
+
21
+ function invalid(mime, label, reason) {
22
+ return new Error("invalid " + mime + (label ? " " + label : "") + ": " + reason + "; re-encode the image; limit is 32 MP across all frames; no image attached");
23
+ }
24
+
25
+ function decodePixels(bytes, mime, signal) {
26
+ signal?.throwIfAborted();
27
+ return new Promise((resolve,reject)=>{
28
+ // BUN_BE_BUN lets a compiled OMP executable run its normal Bun runtime,
29
+ // whose module resolver can load native dependencies from this package.
30
+ const child = childProcess.spawn(process.execPath,[workerPath,mime],{
31
+ env:{...process.env,BUN_BE_BUN:"1"},stdio:["pipe","ignore","pipe"],windowsHide:true,
32
+ });
33
+ let stderr = "", timedOut = false;
34
+ const abort = ()=>child.kill("SIGKILL");
35
+ const timer = setTimeout(()=>{timedOut=true;abort();},5000);
36
+ const finish = error=>{
37
+ clearTimeout(timer);
38
+ signal?.removeEventListener("abort",abort);
39
+ if (error) reject(error); else resolve();
40
+ };
41
+ child.stderr.on("data",chunk=>{stderr += chunk.toString().slice(0,Math.max(0,2048-stderr.length));});
42
+ child.stdin.on("error",()=>{}); // Early decoder exit may close stdin first.
43
+ child.once("error",finish);
44
+ child.once("close",code=>{
45
+ if (signal?.aborted) finish(signal.reason ?? new Error("aborted"));
46
+ else if (timedOut) finish(new Error("image decoding exceeded 5000 ms"));
47
+ else finish(code === 0 ? undefined : new Error(stderr.trim() || "image decoder exited before validation"));
48
+ });
49
+ signal?.addEventListener("abort",abort,{once:true});
50
+ if (signal?.aborted) abort();
51
+ child.stdin.end(bytes);
52
+ });
53
+ }
54
+
55
+ export async function validateImageBytes(bytes, mime, label = "", signal) {
56
+ signal?.throwIfAborted();
57
+ assertModelImageMime(mime);
58
+ if (bytes.length > MAX_BYTES) throw invalid(mime,label,"encoded image exceeds 20 MiB");
59
+ if (!matchesSignature(bytes,mime)) throw invalid(mime,label,"signature does not match declared format");
60
+ const key = mime + ":" + createHash("sha256").update(bytes).digest("hex");
61
+ const work = tail.then(async()=>{
62
+ signal?.throwIfAborted();
63
+ if (verified.has(key)) return;
64
+ if (mime === "image/png") assertPng(bytes,label);
65
+ try { await decodePixels(bytes,mime,signal); }
66
+ catch (error) { signal?.throwIfAborted(); throw invalid(mime,label,errorMessage(error)); }
67
+ verified.add(key);
68
+ if (verified.size > 16) verified.delete(verified.values().next().value);
69
+ });
70
+ // Serialize native raster allocations, not normal reads/guests. Failed or
71
+ // cancelled validation must never poison the next image's queue slot.
72
+ tail = work.catch(()=>{});
73
+ await work;
74
+ }
75
+
76
+ export async function validateReturnedImages(images, signal) {
77
+ for (const image of images ?? []) await validateImageBytes(decodeImageData(image.data),image.mimeType,"",signal);
78
+ }
@@ -0,0 +1,57 @@
1
+ import * as zlib from "node:zlib";
2
+ import {isFunction} from "./decode.js";
3
+ // Fast container preflight before full decoding. Older Node/Bun versions use the
4
+ // portable CRC fallback instead of requiring node:zlib.crc32.
5
+ const signature = Buffer.from([137,80,78,71,13,10,26,10]);
6
+ const crcTable = Uint32Array.from({length:256}, (_, value) => {
7
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
8
+ return value >>> 0;
9
+ });
10
+
11
+ function crc32(bytes) {
12
+ if (isFunction(zlib.crc32)) return zlib.crc32(bytes);
13
+ let crc = 0xffffffff;
14
+ for (const byte of bytes) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 255];
15
+ return (crc ^ 0xffffffff) >>> 0;
16
+ }
17
+
18
+ function invalid(reason, label) {
19
+ throw new Error("invalid PNG" + (label ? " " + label : "") + ": " + reason + "; re-encode the image as PNG before reading/returning it; no image attached");
20
+ }
21
+
22
+ function chunkAt(bytes, offset, label) {
23
+ if (bytes.length - offset < 12) invalid("truncated chunk", label);
24
+ const length = bytes.readUInt32BE(offset);
25
+ const type = bytes.toString("ascii",offset+4,offset+8);
26
+ if (!/^[A-Za-z]{4}$/.test(type)) invalid("invalid chunk type", label);
27
+ const end = offset + 8 + length;
28
+ if (end + 4 > bytes.length) invalid("truncated " + type + " chunk", label);
29
+ if (crc32(bytes.subarray(offset+4,end)) !== bytes.readUInt32BE(end)) invalid(type + " checksum mismatch", label);
30
+ return {type,length,end:end+4};
31
+ }
32
+
33
+ function checkHeader(bytes, chunk, offset, label) {
34
+ if (offset !== 8) {
35
+ if (chunk.type === "IHDR") invalid("duplicate IHDR", label);
36
+ return;
37
+ }
38
+ if (chunk.type !== "IHDR" || chunk.length !== 13) invalid("missing or invalid IHDR", label);
39
+ if (!bytes.readUInt32BE(offset+8) || !bytes.readUInt32BE(offset+12)) invalid("empty dimensions", label);
40
+ }
41
+
42
+ /** Reject corrupt attachments before a read can cross bash or a result can commit. */
43
+ export function assertPng(bytes, label = "") {
44
+ if (!bytes.subarray(0,8).equals(signature)) invalid("signature mismatch", label);
45
+ let offset = 8, imageData = false;
46
+ while (offset < bytes.length) {
47
+ const chunk = chunkAt(bytes,offset,label);
48
+ checkHeader(bytes,chunk,offset,label);
49
+ if (chunk.type === "IDAT") imageData = true;
50
+ if (chunk.type === "IEND") {
51
+ if (chunk.length || !imageData || chunk.end !== bytes.length) invalid("invalid IEND or missing IDAT", label);
52
+ return;
53
+ }
54
+ offset = chunk.end;
55
+ }
56
+ invalid("missing IEND", label);
57
+ }
@@ -0,0 +1,77 @@
1
+ import {isString,isObject} from './decode.js';
2
+
3
+ export function textResult(text, details) {
4
+ return {
5
+ content: [{ type: "text", text: String(text ?? "") }],
6
+ details: details || {},
7
+ };
8
+ }
9
+
10
+ export function resultDiff(response) {
11
+ let details = response?.details;
12
+
13
+ if (isString(details)) {
14
+ try {
15
+ details = JSON.parse(details);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ return isObject(details) ? details.diff : undefined;
22
+ }
23
+
24
+ // Only native adapters can attach this host-local marker. It never crosses RPC;
25
+ // the bridge sends a typed-value flag instead of asking the guest to guess JSON.
26
+ export const READ_VALUE = Symbol("native read value");
27
+ export const READ_BYTES = Symbol("native read bytes");
28
+ export const READ_PREVIEW = Symbol("native read preview");
29
+ export const MAX_READ_VALUE_BYTES = 64 * 1024 * 1024;
30
+
31
+ function retainedReadValue(value) {
32
+ return isString(value) || isObject(value) || Array.isArray(value);
33
+ }
34
+
35
+ function containerBytes(value, pending, seen) {
36
+ if (seen.has(value)) return 8;
37
+ seen.add(value);
38
+ let bytes = 32;
39
+ const array = Array.isArray(value);
40
+ const keys = array ? value.keys() : Object.keys(value);
41
+ for (const key of keys) {
42
+ bytes += array ? 8 : 24 + 2 * key.length;
43
+ if (retainedReadValue(value[key])) pending.push(value[key]);
44
+ }
45
+ return bytes;
46
+ }
47
+
48
+ /** Conservative storage estimate, bounded while walking and aware of aliases. */
49
+ export function readValueBytes(value, limit = Infinity, seen = new WeakSet()) {
50
+ const pending = [value];
51
+ let bytes = 0;
52
+ while (pending.length) {
53
+ const item = pending.pop();
54
+ if (isString(item)) bytes += 2*item.length;
55
+ else if (isObject(item) || Array.isArray(item)) bytes += containerBytes(item,pending,seen);
56
+ else bytes += 8;
57
+ if (bytes > limit) throw new Error("read value exceeds " + limit + " bytes of remaining storage budget; select fewer fields or smaller slices");
58
+ }
59
+ return bytes;
60
+ }
61
+
62
+ export function readResult(value, details = {}, preview = isString(value) ? value : "", bytes = readValueBytes(value), render) {
63
+ let content;
64
+ // Native consumers retain their text-content API. The bridge/trace use the
65
+ // typed value/preview, so ordinary RPCs never serialize this second copy.
66
+ return {get content() {
67
+ const text = () => render ? render() : isString(value) && !details.json ? value : JSON.stringify(value);
68
+ return content ??= [{type:"text",text:text()}];
69
+ }, details, [READ_VALUE]:value, [READ_BYTES]:bytes, [READ_PREVIEW]:preview};
70
+ }
71
+
72
+ export function asReadResult(raw) {
73
+ if (Object.hasOwn(raw, READ_VALUE)) return raw;
74
+ const image = raw.content?.find(part => part.type === "image");
75
+ const value = image ?? raw.content?.filter(part => part.type === "text").map(part => part.text).join("\n") ?? "";
76
+ return { ...raw, [READ_VALUE]: value, [READ_BYTES]: readValueBytes(value) };
77
+ }
@@ -20,12 +20,70 @@ export function errorContext(source, error) {
20
20
  export function parsePosition(message, source) {
21
21
  const located = /\(line (\d+) column (\d+)\)/.exec(String(message));
22
22
 
23
- if (located) return { line: Number(located[1]), column: Number(located[2]) };
23
+ if (located) return { line: Number(located[1]), column: Number(located[2]) - 1 };
24
24
  const offsetMatch = /at position (\d+)/.exec(String(message));
25
25
 
26
26
  if (!offsetMatch) return null;
27
- const before = String(source).slice(0, Number(offsetMatch[1]));
28
- const lines = before.split("\n");
27
+ return offsetPosition(source, Number(offsetMatch[1]));
28
+ }
29
+
30
+ function offsetPosition(source, offset) {
31
+ const lines = String(source).slice(0, offset).split("\n");
29
32
 
30
33
  return { line: lines.length, column: lines.at(-1).length };
31
34
  }
35
+
36
+ // Diagnostic-only JSON grammar: recover a failing token when the engine omits
37
+ // offsets. JSON.parse remains the authority; cap extra work at 65,536 characters.
38
+ // eslint-disable-next-line no-control-regex -- RFC 8259 excludes unescaped control characters from strings.
39
+ const JSON_TOKEN = /("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\da-fA-F]{4}))*")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|[{}[\]:,]/y;
40
+ const JSON_NEXT = {
41
+ end: { eof: [] },
42
+ value: { string: [], number: [], literal: [], "{": ["object"], "[": ["array"] },
43
+ array: { "]": [] },
44
+ arrayNext: { "]": [], ",": ["arrayNext", "value"] },
45
+ object: { "}": [] },
46
+ key: { string: ["colon"] },
47
+ colon: { ":": ["objectNext", "value"] },
48
+ objectNext: { "}": [], ",": ["key"] },
49
+ };
50
+
51
+ function jsonTokenAt(source, start) {
52
+ while (start < source.length && " \t\r\n".includes(source[start])) start++;
53
+ JSON_TOKEN.lastIndex = start;
54
+ const match = JSON_TOKEN.exec(source);
55
+
56
+ if (!match) return { start, end: start, kind: start === source.length ? "eof" : "invalid" };
57
+ const kind = ["string", "number", "literal"][match.slice(1).findIndex(Boolean)] ?? match[0];
58
+
59
+ return { start, end: JSON_TOKEN.lastIndex, kind };
60
+ }
61
+
62
+ function invalidJsonOffset(source) {
63
+ if (source.length > 64 * 1024) return null;
64
+ const stack = ["end", "value"];
65
+ let offset = 0;
66
+
67
+ while (stack.length) {
68
+ const token = jsonTokenAt(source, offset);
69
+ let state = stack.pop();
70
+ if (state === "array" && token.kind !== "]") { stack.push("arrayNext"); state = "value"; }
71
+ if (state === "object" && token.kind !== "}") state = "key";
72
+ const next = JSON_NEXT[state][token.kind];
73
+ if (!next) return token.start;
74
+ stack.push(...next);
75
+ offset = token.end;
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ export function jsonErrorContext(message, source) {
82
+ const native = parsePosition(message, source);
83
+ if (native) return sourceContext(source, native.line, native.column);
84
+ const offset = invalidJsonOffset(source);
85
+ if (offset === null) return "";
86
+ const { line, column } = offsetPosition(source, offset);
87
+
88
+ return " (near line " + line + " column " + (column + 1) + ")" + sourceContext(source, line, column);
89
+ }
@@ -0,0 +1,104 @@
1
+ import {isObject,isFunction} from '../shared/decode.js';
2
+
3
+ function isTheme(value) {
4
+ return isObject(value) && isFunction(value.fg);
5
+ }
6
+
7
+ /**
8
+ * Dual-host renderCall args:
9
+ * Pi: (args, theme, context)
10
+ * OMP: (args, options/renderState, theme)
11
+ */
12
+ export function normalizeCallRenderArgs(a, b, c) {
13
+ if (isTheme(b)) {
14
+ const context = isObject(c) ? c : {};
15
+
16
+ if (!isObject(context.state)) context.state = {};
17
+
18
+ return { args: a, theme: b, context, host: "pi" };
19
+ }
20
+
21
+ if (isTheme(c)) {
22
+ const options = isObject(b) ? b : {};
23
+
24
+ if (!isObject(options.state)) options.state = {};
25
+
26
+ const context = {
27
+ ...options,
28
+ state: options.state,
29
+ expanded: options.expanded,
30
+ isPartial: options.isPartial,
31
+ executionStarted: options.executionStarted,
32
+ argsComplete: options.argsComplete,
33
+ lastComponent: options.lastComponent,
34
+ invalidate: options.invalidate,
35
+ };
36
+
37
+ return { args: a, theme: c, context, host: "omp", options };
38
+ }
39
+
40
+ throw new Error("supernova renderCall: theme missing (expected Pi or OMP signature)");
41
+ }
42
+
43
+ /**
44
+ * Dual-host renderResult args:
45
+ * Pi: (result, {expanded,isPartial}, theme, context)
46
+ * OMP: (result, {expanded,isPartial}, theme, args) (4th is args, not context)
47
+ *
48
+ * Call shapes share the first three positions, so host is inferred from the
49
+ * fourth argument's context-versus-args shape.
50
+ */
51
+ function contextFrom(opts, ctxOrArgs) {
52
+ if (isObject(ctxOrArgs) && !isTheme(ctxOrArgs)) {
53
+ if ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs) return ctxOrArgs;
54
+ }
55
+
56
+ return { state: opts.state, lastComponent: opts.lastComponent };
57
+ }
58
+
59
+ function isRenderContext(value) {
60
+ return "lastComponent" in value || "invalidate" in value;
61
+ }
62
+
63
+ function isToolArgs(value) {
64
+ return "code" in value || "file" in value || "programs" in value || "timeoutMs" in value;
65
+ }
66
+
67
+ function detectResultHost(options, ctxOrArgs) {
68
+ if (isTheme(options) || !isObject(ctxOrArgs) || isRenderContext(ctxOrArgs)) return "pi";
69
+ return isToolArgs(ctxOrArgs) ? "omp" : "pi";
70
+ }
71
+
72
+ function ensureState(context) {
73
+ if (!isObject(context.state)) context.state = {};
74
+
75
+ return context;
76
+ }
77
+
78
+ function resultArgs(ctxOrArgs, context) {
79
+ return ctxOrArgs?.code || ctxOrArgs?.file || ctxOrArgs?.programs ? ctxOrArgs : context.args;
80
+ }
81
+
82
+ function piResultArgs(result, options, theme, ctxOrArgs) {
83
+ const opts = isObject(options) ? options : {};
84
+ const context = ensureState(contextFrom(opts, ctxOrArgs));
85
+ return resultRenderModel(result, theme, context, opts, opts, detectResultHost(options, ctxOrArgs), resultArgs(ctxOrArgs, context));
86
+ }
87
+
88
+ function resultRenderModel(result, theme, context, flags, options, host, args) {
89
+ return { result, theme, context, options, host, args, expanded: !!flags.expanded, isPartial: !!flags.isPartial };
90
+ }
91
+
92
+ function oddballResultArgs(result, theme, themeOrCtx) {
93
+ const context = ensureState(isObject(themeOrCtx) ? themeOrCtx : {});
94
+ return resultRenderModel(result, theme, context, context, {}, "pi", context.args);
95
+ }
96
+
97
+ function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
98
+ if (isTheme(themeOrCtx)) return piResultArgs(result, options, themeOrCtx, ctxOrArgs);
99
+
100
+ if (isTheme(options)) return oddballResultArgs(result, options, themeOrCtx);
101
+
102
+ throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
103
+ }
104
+ export {normalizeResultRenderArgs};
@@ -0,0 +1,51 @@
1
+ import {isFunction} from '../shared/decode.js';
2
+
3
+ const PROGRESS_FRAME_MS = 80;
4
+
5
+ /**
6
+ * Live trace updates for the card. The first update is immediate (seeds the result slot);
7
+ * later ones are coalesced to one host re-render per frame so a tight loop of nova.calls
8
+ * is not throttled by the TUI. A throwing host callback must never break the run.
9
+ */
10
+ export function progressEmitter(onUpdate) {
11
+ if (!isFunction(onUpdate)) return Object.assign(() => {}, { flush() {} });
12
+ let pending = null;
13
+ let timer = null;
14
+ let lastSent = -Infinity;
15
+
16
+ const send = () => {
17
+ timer = null;
18
+
19
+ if (pending === null) return;
20
+ // Snapshot only at emission, not on every tool event. Completed records must
21
+ // not mutate a previously emitted frame while Pi is still consuming it.
22
+ const trace = pending.map(record => ({ ...record }));
23
+ pending = null;
24
+ lastSent = performance.now();
25
+
26
+ try {
27
+ onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
28
+ } catch {}
29
+ };
30
+
31
+ const emit = (trace) => {
32
+ pending = trace;
33
+
34
+ if (timer !== null) return;
35
+ const wait = PROGRESS_FRAME_MS - (performance.now() - lastSent);
36
+
37
+ if (wait <= 0) send();
38
+ else {
39
+ timer = setTimeout(send, wait);
40
+ timer.unref?.();
41
+ }
42
+ };
43
+
44
+ emit.flush = () => {
45
+ if (timer !== null) clearTimeout(timer);
46
+ pending = null;
47
+ timer = null;
48
+ };
49
+
50
+ return emit;
51
+ }