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
@@ -1,4 +1,5 @@
1
- import { isObject, isString } from "../shared/decode.js";
1
+ import {parseBatchPayload,batchTimeoutMs} from "./batch-input.js";
2
+ import { isString } from "../shared/decode.js";
2
3
  import { truncateChars } from "../output/format.js";
3
4
 
4
5
  const textOf = result => (Array.isArray(result?.content) ? result.content : []).filter(block => block?.type === "text").map(block => block.text).join("\n");
@@ -28,66 +29,6 @@ export function programBatchText(results, total, stopped = "", failed = 0) {
28
29
  }).join("");
29
30
  }
30
31
 
31
- function assertProgramEntry(p, defaults = {}) {
32
- const source = p?.code === undefined && p?.file === undefined ? defaults : p;
33
-
34
- if (!isObject(p) || Array.isArray(p) || Object.keys(p).some(key => !["code","file","data"].includes(key)) ||
35
- ((source.code === undefined) === (source.file === undefined)) || !isString(source.code ?? source.file) || !(source.code ?? source.file).trim()) {
36
- 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");
37
- }
38
- }
39
-
40
- const objectData = value => isObject(value) && !Array.isArray(value);
41
-
42
- function applyBatchDefaults(parsed, mergeData) {
43
- if (mergeData && !objectData(parsed.data)) throw new Error("mergeData requires top-level object data; no programs ran");
44
- const source = parsed.code !== undefined ? {code:parsed.code} : parsed.file !== undefined ? {file:parsed.file} : {};
45
-
46
- return parsed.programs.map(program => {
47
- assertProgramEntry(program, source);
48
- const entry = program.code === undefined && program.file === undefined ? {...source,...program} : program;
49
-
50
- if (mergeData) {
51
- if (program.data !== undefined && !objectData(program.data)) throw new Error("mergeData requires object data in every explicit entry; no programs ran");
52
- // Shallow own-property overlay, including literal __proto__ keys. The
53
- // runtime snapshots data again per guest; no mutable heap is shared.
54
- entry.data = {...parsed.data,...program.data};
55
- } else if (program.data === undefined && Object.hasOwn(parsed,"data")) entry.data = parsed.data;
56
-
57
- return entry;
58
- });
59
- }
60
-
61
- function parseBatchPayload(params, config) {
62
- if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
63
- if (params.mergeData !== undefined && params.mergeData !== true && params.mergeData !== false) throw new Error("mergeData must be boolean; no programs ran");
64
- const defaults = Object.fromEntries(["code","file","data"].filter(key => params[key] !== undefined).map(key => [key,params[key]]));
65
-
66
- if (defaults.code !== undefined || defaults.file !== undefined) assertProgramEntry(defaults);
67
- for (const p of params.programs) assertProgramEntry(p, defaults);
68
- const hasDefaults = Object.keys(defaults).length > 0;
69
- let encoded;
70
-
71
- try { encoded = JSON.stringify(hasDefaults ? {programs:params.programs,...defaults} : params.programs); } catch { throw new Error("programs and defaults must be JSON-serializable; no programs ran"); }
72
-
73
- if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget (including shared code/file/data); no programs ran");
74
- const parsed = hasDefaults ? JSON.parse(encoded) : {programs:JSON.parse(encoded)};
75
-
76
- if (Object.hasOwn(defaults,"data") && !Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
77
-
78
- // Validate and expand every entry before executing any. Defaults count once
79
- // against admission, not once for each independent guest receiving a copy.
80
- return applyBatchDefaults(parsed, params.mergeData === true);
81
- }
82
-
83
- function batchTimeoutMs(params, config) {
84
- const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
85
-
86
- if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
87
-
88
- return requestedTimeout;
89
- }
90
-
91
32
  const MAX_PARALLEL_PROGRAMS = 8;
92
33
 
93
34
  class ProgramBatch {
@@ -136,7 +77,7 @@ class ProgramBatch {
136
77
  }
137
78
 
138
79
  collectImages(result, i) {
139
- for (const block of Array.isArray(result?.content) ? result.content : []) if (block?.type === "image" && isString(block.data)) {
80
+ for (const block of imageBlocks(result)) {
140
81
  this.imageBytes += Buffer.byteLength(block.data,"base64");
141
82
 
142
83
  if (this.images.length >= 16 || this.imageBytes > 20*1024*1024) { this.imageDropped = true; continue; }
@@ -158,18 +99,9 @@ class ProgramBatch {
158
99
 
159
100
  parallelBudgetStop(settled) {
160
101
  const results = settled.filter(Boolean);
161
- let images = 0, bytes = 0, labelChars = 0;
162
- for (const [i, result] of settled.entries()) {
163
- let imageSeq = 0;
164
- for (const block of result?.content ?? []) if (block.type === "image" && isString(block.data)) {
165
- images++;
166
- bytes += Buffer.byteLength(block.data, "base64");
167
- labelChars += ("program " + (i + 1) + " image " + (++imageSeq)).length + 1;
168
- }
169
- }
102
+ const {images, bytes} = imageTotals(settled);
170
103
  let kind;
171
104
  if (images > 16 || bytes > 20 * 1024 * 1024) kind = "image";
172
- else if (results.some(result => result.details?.returnTruncated) || programBatchText(results, this.programs.length).length + labelChars > this.config.maxReturnChars) kind = "output";
173
105
  else if (results.some(result => result.details?.logTruncated) || results.reduce((n, result) => n + (result.details?.logs?.length ?? 0), 0) > (this.config.maxLogLines ?? 100)) kind = "log";
174
106
  return kind ? "batch " + kind + " budget exceeded; completed commits remain" : "";
175
107
  }
@@ -202,9 +134,8 @@ class ProgramBatch {
202
134
  if (this.imageDropped) stopped = "batch image budget exceeded; remaining programs did not run";
203
135
 
204
136
  if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
205
- const text = programBatchText(this.results, this.programs.length, stopped);
206
-
207
- if (text.length + this.imageTextChars() > this.config.maxReturnChars || result.details?.returnTruncated) stopped ||= "batch output budget exceeded; remaining programs did not run; earlier commits remain";
137
+ // Display clipping is not an execution failure. Finish every requested entry
138
+ // unless a real execution/resource limit stops it; boundedText caps delivery.
208
139
 
209
140
  if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
210
141
 
@@ -270,3 +201,18 @@ export async function runProgramBatch(id, params, signal, onUpdate, ctx, config,
270
201
 
271
202
  return new ProgramBatch(id, params, signal, onUpdate, ctx, config, execute, programs, batchTimeoutMs(params, config)).run();
272
203
  }
204
+
205
+ function imageBlocks(result) {
206
+ return (Array.isArray(result?.content) ? result.content : []).filter(block => block?.type === "image" && isString(block.data));
207
+ }
208
+
209
+ function imageTotals(settled) {
210
+ let images = 0, bytes = 0;
211
+ for (const result of settled) {
212
+ for (const block of imageBlocks(result)) {
213
+ images++;
214
+ bytes += Buffer.byteLength(block.data, "base64");
215
+ }
216
+ }
217
+ return {images, bytes};
218
+ }
@@ -1,3 +1,4 @@
1
+ import { readLimitedBytes } from "../fs/file-io.js";
1
2
  import * as fs from "node:fs/promises";
2
3
  import { resolveWorkspacePath } from "../fs/workspace.js";
3
4
 
@@ -21,22 +22,13 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
21
22
  const maxBytes = chars * 3;
22
23
  const tooLarge = () => new Error("code exceeds " + chars + " characters; split the program");
23
24
 
24
- if (stat.size > maxBytes) throw tooLarge();
25
- const chunks = [];
26
- let bytes = 0;
27
-
28
- for await (const chunk of handle.createReadStream({ end: maxBytes, autoClose: false, signal })) {
29
- bytes += chunk.length;
30
-
31
- if (bytes > maxBytes) throw tooLarge();
32
- chunks.push(chunk);
33
- }
25
+ const bytes = await readLimitedBytes(handle, stat, maxBytes, file, signal, tooLarge);
34
26
 
35
27
  signal?.throwIfAborted();
36
28
  // Do not silently replace invalid bytes in executable source. Preserve BOMs.
37
29
  let code;
38
30
 
39
- try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); }
31
+ try { code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
40
32
  catch { throw new Error("program file " + file + " is not valid UTF-8 (encoded data could not be decoded); save it as UTF-8 text"); }
41
33
 
42
34
  if (code.length > chars) throw tooLarge();
@@ -0,0 +1,141 @@
1
+ import {parse} from 'acorn';
2
+ import {isObject,isString} from '../shared/decode.js';
3
+ import {guestImportMessage,isDeniedGuestImport} from './guest-deny-imports.js';
4
+
5
+ const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true };
6
+
7
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
8
+
9
+ function hasReturn(node) {
10
+ if (!isObject(node)) return false;
11
+
12
+ if (node.type === "ReturnStatement") return true;
13
+
14
+ if (FUNCTION_TYPES.has(node.type)) return false;
15
+
16
+ return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
17
+ }
18
+
19
+ function parseExpressionFunction(code) {
20
+ try {
21
+ const program = parse(code, PARSE_OPTIONS);
22
+ const statements = program.body.filter(node => node.type !== "EmptyStatement");
23
+ const statement = statements.length === 1 ? statements[0] : undefined;
24
+ const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
25
+
26
+ if (candidate && FUNCTION_TYPES.has(candidate.type)) {
27
+ return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
28
+ }
29
+
30
+ return { program };
31
+ } catch (bodyError) {
32
+ const expressionSource = code.trimEnd().replace(/;+\s*$/, "");
33
+
34
+ try {
35
+ const wrapped = parse("(" + expressionSource + "\n)", PARSE_OPTIONS);
36
+ const expression = wrapped.body[0]?.expression;
37
+
38
+ if (!expression || !FUNCTION_TYPES.has(expression.type)) throw bodyError;
39
+
40
+ return { expression, expressionSource };
41
+ } catch { throw bodyError; }
42
+ }
43
+ }
44
+
45
+ function deniedSpecifier(node) {
46
+ if (node?.type === "Literal" && isString(node.value)) return node.value;
47
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
48
+ }
49
+
50
+ function assertGuestImports(node) {
51
+ if (Array.isArray(node)) { node.forEach(assertGuestImports); return; }
52
+ if (!isObject(node)) return;
53
+ if (["ImportDeclaration", "ImportExpression"].includes(node.type)) rejectGuestImport(node.source);
54
+ if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") rejectGuestImport(node.arguments?.[0]);
55
+ for (const key of Object.keys(node)) {
56
+ if (!["start", "end", "loc", "range"].includes(key)) assertGuestImports(node[key]);
57
+ }
58
+ }
59
+
60
+ function prepareProgram(code) {
61
+ const parsed = parseExpressionFunction(code);
62
+ assertGuestImports(parsed.program ?? parsed.expression);
63
+ const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
64
+ const returns = parsed.expression
65
+ ? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
66
+ : hasReturn(parsed.program);
67
+
68
+ return { body, hasReturn: returns };
69
+ }
70
+
71
+ function admitData(data, cap) {
72
+ if (data === undefined) return { data };
73
+
74
+ try {
75
+ const encoded = JSON.stringify(data);
76
+
77
+ if (encoded === undefined) return { error: "data must be JSON-serializable" };
78
+ if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
79
+
80
+ return { data: JSON.parse(encoded) };
81
+ } catch { return { error: "data must be JSON-serializable" }; }
82
+ }
83
+
84
+ function admitCode({ code, file, cap }) {
85
+ if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
86
+ if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
87
+ if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
88
+ }
89
+
90
+ function admitTimeout(config) {
91
+ const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
92
+
93
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return { error: "timeoutMs must be a positive finite number" };
94
+
95
+ return { timeoutMs: Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout))) };
96
+ }
97
+
98
+ function admitGuest({ code, file, data, config }) {
99
+ const cap = config.maxCodeChars ?? 48000;
100
+ const codeError = admitCode({ code, file, cap });
101
+
102
+ if (codeError) return codeError;
103
+ const admitted = admitData(data, cap);
104
+
105
+ if (admitted.error) return admitted;
106
+ const timeout = admitTimeout(config);
107
+
108
+ if (timeout.error) return timeout;
109
+
110
+ return { data: admitted.data, timeoutMs: timeout.timeoutMs };
111
+ }
112
+ export { admitGuest, prepareProgram };
113
+
114
+ function rejectGuestImport(source) {
115
+ const spec = deniedSpecifier(source);
116
+ const reason = spec && isDeniedGuestImport(spec) ? guestImportMessage(spec) : "guest cannot import modules; use read, edit, write, or bash";
117
+ throw new Error(reason + "; no commands ran");
118
+ }
119
+
120
+ function containingAwait(node, offset) {
121
+ if (Array.isArray(node)) return node.map(child => containingAwait(child, offset)).find(Boolean);
122
+ if (!isObject(node) || offset < node.start || offset >= node.end) return null;
123
+ for (const child of Object.values(node)) {
124
+ const found = containingAwait(child, offset);
125
+ if (found) return found;
126
+ }
127
+ return node.type === "AwaitExpression" ? node : null;
128
+ }
129
+
130
+ // V8 points at 'await'; JSC points at the called function's parenthesis. Report
131
+ // the enclosing await expression consistently, using source syntax, not offsets.
132
+ export function normalizeGuestLocation(source, location) {
133
+ if (!location?.awaited) return location;
134
+ const {line, col} = location;
135
+ const prefix = source.split("\n").slice(0, line - 1).join("\n");
136
+ const offset = prefix.length + Number(line > 1) + col - 1;
137
+ try {
138
+ const node = containingAwait(parse(source, {...PARSE_OPTIONS, locations:true}), offset);
139
+ return node ? {line:node.loc.start.line, col:node.loc.start.column + 1} : location;
140
+ } catch { return location; }
141
+ }
@@ -1,15 +1,16 @@
1
1
  // Standing tool description: sent on every request. No result or history compression.
2
2
  export const REFERENCE = `JS body/async arrow: read/write/edit/bash; no fs/import/require. file: workspace scripts; data: literals (≤48000 JSON chars).
3
3
  read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 images/20 MiB).
4
- Path-only ≤160 lines AND 8192 characters (UTF-16); larger: read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded bash parser.
5
- read({path,json:selector}) → parsed JSON: ".field", ".a[0:3]", ".a.length", quoted keys, true; ≤16 MiB, no jq. {status:"too_large",keys|length} → narrow selector.
4
+ Text ≤64 MiB internally; complete:true requires the whole file. Display alone is capped; return a summary or read(path,{offset:1,limit:80}). Larger files/JSONL: bounded bash parser.
5
+ read({path,json:selector}) → parsed JSON: ".field", ".a[0:3]", ".a.length", quoted keys, true; input ≤16 MiB, selections ≤64 MiB storage, no jq. Values retain their types.
6
6
  read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
7
7
  read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
8
8
  write(path,text) replaces unread workspace files; write({path,content,append:true}) appends without reading. After read: edit or replace:true.
9
9
  edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) unique exact read text; returns numbered windows/checks/references.
10
10
  edit(view,text) replaces span; edit(view,old,new) uniquely matches within it. edit(async()=>{...}) checkpoint: merge on success, rollback/rethrow on failure.
11
11
  bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv for scripts; bounded output, nonzero throws. Outer timeoutMs caps ALL waits/commands; bash inherits unless overridden.
12
- Edits stage until success; bash commits first. Array errors abort; Promise.allSettled for optional reads.
13
- programs:[{code?,file?,data?}] inherits code OR file and data. Entries override source/data; mergeData:true shallow-merges objects (entry keys win).
14
- Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known reads/checks (Promise.all) + edits/verification in ONE call; split for new decisions.
12
+ Edits commit on success/before bash. Promise.allSettled keeps partial results.
13
+ programs:[{code?,file?,data?}] inherits source/data; entries override. mergeData:true shallow object merge (entry keys win).
14
+ Promise.all: ≤8 reads or disjoint-file mutations; same-file serial; bash/checkpoint barriers.
15
+ Fresh guests/separate commits. Sequential failure stops; prior commits stay. parallel:true for disjoint entries. ONE call for known work; split for new decisions.
15
16
  `;