pi-supernova 0.5.0 → 0.7.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 (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -0,0 +1,41 @@
1
+ import { relativeSlash } from "../fs/workspace.js";
2
+
3
+ export function outlineOptions(params, references, config) {
4
+ const options = { references };
5
+
6
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
7
+
8
+ return options;
9
+ }
10
+
11
+ /** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
12
+ export function recordOutlineOrigins(ledger, rel, outlineText) {
13
+ for (const line of outlineText.split("\n")) {
14
+ const m = /^\s*(\d+) (.*)$/.exec(line);
15
+
16
+ if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
17
+ }
18
+ }
19
+
20
+ /** Where else a name appears (declaration line excluded), for outlines and edit results. */
21
+ export function createReferenceFinder(index, vfs) {
22
+ return async function referenceFinder(cwd, targetPath) {
23
+ let files;
24
+
25
+ try { files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])]; }
26
+ catch { return () => []; }
27
+
28
+ if (!index.canScan(files)) return () => [];
29
+
30
+ return (name, excludeLine) => {
31
+ if (!name || name.length < 3) return [];
32
+ const escaped = name.replace(/[$]/g, (c) => "\\" + c);
33
+ const regex = new RegExp("\\b" + escaped + "\\b");
34
+
35
+ return index
36
+ .grepRows(files, regex, cwd, file => vfs.getOverlay(file))
37
+ .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
38
+ .map((r) => r.rel + ":" + r.line);
39
+ };
40
+ };
41
+ }
@@ -0,0 +1,96 @@
1
+ import * as path from "node:path";
2
+ import { isString } from "../shared/decode.js";
3
+ import { buildWriteDiff } from "../fs/diff.js";
4
+ import { quickCheck } from "../fs/check.js";
5
+ import { resolveWorkspacePath, relativeSlash } from "../fs/workspace.js";
6
+ import {
7
+ textResult, contentLineInfo, boundedWriteDiff,
8
+ WRITE_DIFF_MAX_READ_BYTES, WRITE_APPEND_MAX_READ_BYTES, QUICK_CHECK_MAX_CHARS,
9
+ writeSnapshot,
10
+ } from "../fs/text-ops.js";
11
+
12
+ const READ_ARTIFACT_MARK = /\[read truncated;|…\[[^\]\n]*truncated[^\]\n]*\]…/u;
13
+
14
+ function assertWriteAppendFlag(append) {
15
+ if (append !== undefined && append !== true && append !== false) throw new Error("write append must be a boolean");
16
+ }
17
+
18
+ function assertWriteArtifactsFlag(allowReadArtifacts) {
19
+ if (allowReadArtifacts !== undefined && allowReadArtifacts !== true && allowReadArtifacts !== false) throw new Error("write allowReadArtifacts must be a boolean");
20
+ }
21
+
22
+ function writeDiffFor(target, prevText, content, removedLines) {
23
+ if (removedLines === undefined && content.length <= WRITE_DIFF_MAX_READ_BYTES) {
24
+ return buildWriteDiff(target, prevText, content);
25
+ }
26
+
27
+ return boundedWriteDiff(target, content, removedLines ?? contentLineInfo(prevText).count);
28
+ }
29
+
30
+ function writeCheckWarning(content, target) {
31
+ if (content.length > QUICK_CHECK_MAX_CHARS) return "";
32
+ const check = quickCheck(content, path.extname(target));
33
+
34
+ return check && !check.ok ? "\ncheck: " + check.message : "";
35
+ }
36
+
37
+ function writeOutcome(rel, target, content, speculative, prevText, removedLines) {
38
+ const diff = writeDiffFor(target, prevText, content, removedLines);
39
+ const tag = speculative ? " (speculative)" : "";
40
+
41
+ return textResult(`wrote ${rel}${tag}${writeCheckWarning(content, target)}`, { path: target, speculative, diff });
42
+ }
43
+
44
+ export function createWrite(ctx) {
45
+ const { getCwd, vfs, index } = ctx;
46
+
47
+ function assertWriteParams(params) {
48
+ if (!isString(params?.content)) throw new Error("write requires string content");
49
+ assertWriteAppendFlag(params.append);
50
+ assertWriteArtifactsFlag(params.allowReadArtifacts);
51
+ const content = String(params.content);
52
+
53
+ if (params.allowReadArtifacts !== true && READ_ARTIFACT_MARK.test(content)) {
54
+ throw new Error("refusing to write truncated read output; use edit() or reconstruct complete source windows. Set allowReadArtifacts:true only to intentionally write literal truncation-marker text");
55
+ }
56
+
57
+ return content;
58
+ }
59
+
60
+ async function applyAppend(target, content, snap) {
61
+ let { previous: prevText, overlay, existingBytes } = snap;
62
+
63
+ if (existingBytes > WRITE_APPEND_MAX_READ_BYTES) throw new Error("append input exceeds " + WRITE_APPEND_MAX_READ_BYTES + " bytes; stream it with bash redirection instead");
64
+
65
+ if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
66
+ try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
67
+ catch (error) { if (error.code !== "ENOENT") throw error; }
68
+ }
69
+
70
+ return { content: prevText + content, prevText, removedLines: undefined };
71
+ }
72
+
73
+ async function write(params, signal) {
74
+ const cwd = getCwd();
75
+ const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
76
+
77
+ if (signal?.aborted) throw new Error("aborted");
78
+ let content = assertWriteParams(params);
79
+ const snap = await writeSnapshot(vfs, target, signal);
80
+ let { previous: prevText, removedLines } = snap;
81
+
82
+ if (params.append === true) {
83
+ const appended = await applyAppend(target, content, snap);
84
+ content = appended.content;
85
+ prevText = appended.prevText;
86
+ removedLines = appended.removedLines;
87
+ }
88
+
89
+ const { speculative } = await vfs.write(target, content);
90
+ index.touch(relativeSlash(cwd, target));
91
+
92
+ return writeOutcome(relativeSlash(cwd, target), target, content, speculative, prevText, removedLines);
93
+ }
94
+
95
+ return { write };
96
+ }
@@ -1,257 +1,67 @@
1
+ import { isString } from "../shared/decode.js";
1
2
 
2
- import { isString, isObject } from "../shared/decode.js";
3
-
4
- const NATIVE_TOOL_DEFINITIONS = [
5
- {
6
- name: "read",
7
- description: "Read files, images or directories. JSON selectors project full documents within output budgets. Source questions locate and open source directly; resolve returns structured source/status without guessing.",
8
- parameters: { type: "object", properties: {
9
- path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Workspace-relative file or directory, source question, or array of paths" },
10
- target: { anyOf: [{ type: "string" }, { type: "array" }], description: "File path/query or array of paths" },
11
- offset: { type: "number", description: "One-based starting line" },
12
- limit: { type: "number", description: "Maximum lines to return" },
13
- about: { type: "string", description: "Question or symbol: expand file bodies, or locate and open source inside a directory" },
14
- query: { type: "string", description: "Source question; optional path scopes the search directory" },
15
- resolve: { type: "boolean", description: "Return structured source/status for a direct resolve-to-edit handoff" },
16
- complete: { type: "boolean", description: "Fail unless the entire requested file fits without clipping" },
17
- json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" } }], description: "Parse the complete JSON input (up to 16 MiB), then select .field, .items[0:3], or quoted keys. A selector array returns an array of values; true selects the root. Oversized selections fail, never clip." },
18
- } },
19
- },
20
- {
21
- name: "write", description: "Write UTF-8 content to a workspace file.",
22
- parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, allowReadArtifacts: { type: "boolean", description: "Explicit opt-in for intentionally writing literal truncation-marker text" } }, required: ["path", "content"] },
23
- },
24
- {
25
- name: "edit", description: "Apply unique text replacements to a workspace file; returns the post-edit lines, a structural check, and references to changed declarations.",
26
- parameters: { type: "object", properties: {
27
- path: { type: "string" }, oldText: { type: "string" }, newText: { type: "string" }, edits: { type: "array", description: "[{oldText, newText}] for several replacements in one call" },
28
- }, required: ["path"] },
29
- },
30
- {
31
- name: "apply_patch", description: "Apply a unified diff to one workspace file.",
32
- parameters: { type: "object", properties: { path: { type: "string" }, patch: { type: "string" } }, required: ["patch"] },
33
- },
34
- {
35
- name: "snap", description: "Select source with found, ambiguous, not_found, or incomplete status. The read command uses the same engine.",
36
- parameters: { type: "object", properties: {
37
- query: { type: "string", description: "Source concept to resolve" },
38
- path: { type: "string", description: "Optional workspace search root; explicitly targeting a hidden directory includes its hidden files, but Git metadata is always excluded" },
39
- }, required: ["query"] },
40
- },
41
- {
42
- name: "evidence", description: "Top-K source spans (with path and line provenance) that answer a concept question; read these instead of whole files.",
43
- parameters: { type: "object", properties: {
44
- query: { type: "string", description: "Concept, symbol, or question" },
45
- path: { type: "string", description: "Optional search root" },
46
- k: { type: "number", description: "Main spans to return (default 5)" },
47
- maxChars: { type: "number", description: "Total text budget (default 6000)" },
48
- }, required: ["query"] },
49
- },
50
- {
51
- name: "surface", description: "Extract a structural outline from a workspace source file.",
52
- parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
53
- },
54
- {
55
- name: "bash", description: "Run a shell command inside the workspace and capture bounded output.",
56
- parameters: { type: "object", properties: { command: { type: "string" }, cwd: { type: "string" }, timeoutMs: { type: "number" } }, required: ["command"] },
57
- },
58
- {
59
- name: "grep", description: "Search file contents. Smart-case regex; definition lines first (marked *); fuzzy fallback when nothing matches literally.",
60
- parameters: { type: "object", properties: {
61
- pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" }, caseSensitive: { type: "boolean" }, limit: { type: "number" },
62
- }, required: ["pattern"] },
63
- },
64
- {
65
- name: "glob", description: "Find files: a glob pattern, or free text for typo-tolerant, frecency-ranked path search.",
66
- parameters: { type: "object", properties: { pattern: { type: "string" } }, required: ["pattern"] },
67
- },
68
- {
69
- name: "find", description: "List workspace files, optionally constrained by path and pattern.",
70
- parameters: { type: "object", properties: { path: { type: "string" }, pattern: { type: "string" }, glob: { type: "string" } } },
71
- },
72
- {
73
- name: "ls", description: "List direct entries in a workspace directory.",
74
- parameters: { type: "object", properties: { path: { type: "string" } } },
75
- },
76
- ];
77
-
78
- export function mergeNativeToolDefinitions(tools, capturedNames = []) {
79
- const nativeByName = new Map(NATIVE_TOOL_DEFINITIONS.map((tool) => [tool.name, tool]));
80
- const captured = new Set(capturedNames);
81
- const seen = new Set();
82
- const merged = [];
83
-
84
- for (const tool of tools || []) {
85
- const fallback = nativeByName.get(tool?.name);
86
- merged.push(fallback && !captured.has(tool.name)
87
- ? { ...tool, ...fallback, sourceInfo: { path: "<native:" + tool.name + ">" } }
88
- : tool);
89
-
90
- if (tool?.name) seen.add(tool.name);
91
- }
92
-
93
- for (const fallback of NATIVE_TOOL_DEFINITIONS) {
94
- if (!seen.has(fallback.name)) {
95
- merged.push({ ...fallback, sourceInfo: { path: "<native:" + fallback.name + ">" } });
96
- }
97
- }
98
-
99
- return merged;
100
- }
101
-
102
- function sourcePathOf(tool) {
103
- if (tool.sourceInfo && isString(tool.sourceInfo.path)) return tool.sourceInfo.path;
104
-
105
- if (isString(tool.extensionPath)) return tool.extensionPath;
106
-
107
- if (isString(tool.sourcePath)) return tool.sourcePath;
108
-
109
- return undefined;
110
- }
111
-
112
- function normalizeTool(tool) {
113
- if (!tool || !isObject(tool)) return null;
114
- const name = isString(tool.name) ? tool.name : "";
115
-
116
- if (!name) return null;
117
- const description = isString(tool.description) ? tool.description : "";
118
-
119
- return {
120
- name,
121
- nameLower: name.toLowerCase(),
122
- description,
123
- descLower: description.toLowerCase(),
124
- parameters: tool.parameters,
125
- schemaError: tool.schemaError,
126
- sourcePath: sourcePathOf(tool),
127
- };
128
- }
129
-
130
- export function buildCatalog(tools, excludeNames = []) {
131
- const exclude = new Set(excludeNames);
132
- const rows = [];
133
-
134
- for (const tool of tools || []) {
135
- const row = normalizeTool(tool);
136
-
137
- if (!row || exclude.has(row.name)) continue;
138
- rows.push(row);
139
- }
3
+ /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
4
+ function osaCell(a, b, rows, i, j) {
5
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
6
+ let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
140
7
 
141
- rows.sort((a, b) => a.name.localeCompare(b.name));
8
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, rows[i - 2][j - 2] + 1);
142
9
 
143
- return rows;
10
+ return best;
144
11
  }
145
12
 
146
- function tokenize(query) {
147
- return String(query || "")
148
- .toLowerCase()
149
- .split(/[^a-z0-9_]+/g)
150
- .filter((t) => t.length > 1);
151
- }
13
+ function editDistance(a, b) {
14
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
152
15
 
153
- function scoreRow(row, tokens) {
154
- if (tokens.length === 0) return 1;
155
- const name = row.nameLower || row.name.toLowerCase();
156
- const desc = row.descLower || row.description.toLowerCase();
157
- let score = 0;
16
+ for (let j = 1; j <= b.length; j++) rows[0][j] = j;
158
17
 
159
- for (const token of tokens) {
160
- if (name === token) score += 10;
161
- else if (name.includes(token)) score += 5;
162
- else if (desc.includes(token)) score += 2;
18
+ for (let i = 1; i < rows.length; i++) {
19
+ for (let j = 1; j <= b.length; j++) rows[i][j] = osaCell(a, b, rows, i, j);
163
20
  }
164
21
 
165
- return score;
22
+ return rows[a.length][b.length];
166
23
  }
167
24
 
168
- export function searchCatalog(catalog, query, limit = 12) {
169
- const tokens = tokenize(query);
170
- const scored = [];
25
+ function scoreName(needle, candidate, maxDistance) {
26
+ const lower = candidate.toLowerCase();
171
27
 
172
- for (const row of catalog) {
173
- const score = scoreRow(row, tokens);
28
+ if (lower === needle) return null;
29
+ const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
174
30
 
175
- if (score <= 0 && tokens.length > 0) continue;
176
- scored.push({ name: row.name, description: row.description.slice(0, 160), score });
177
- }
31
+ if (distance > maxDistance) return null;
178
32
 
179
- scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
180
-
181
- return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
33
+ return { candidate, distance };
182
34
  }
183
35
 
184
- /** Optimal string alignment distance: insert/delete/substitute/adjacent-transpose cost 1. */
185
- function editDistance(a, b) {
186
- const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
187
-
188
- for (let j = 1; j <= b.length; j++) rows[0][j] = j;
189
-
190
- for (let i = 1; i <= a.length; i++) {
191
- for (let j = 1; j <= b.length; j++) {
192
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
193
- let best = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
194
-
195
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, rows[i - 2][j - 2] + 1);
196
- rows[i][j] = best;
197
- }
198
- }
199
-
200
- return rows[a.length][b.length];
36
+ function bySuggestionRank(needle, a, b) {
37
+ return (
38
+ a.distance - b.distance ||
39
+ Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
40
+ a.candidate.localeCompare(b.candidate)
41
+ );
201
42
  }
202
43
 
203
- /** Closest tool names for a mistyped name: substring hits first, then a length-scaled edit distance. */
204
44
  function suggestNames(name, candidates, limit = 3) {
205
- const needle = String(name || "").toLowerCase();
45
+ const needle = String(name || "").toLowerCase().slice(0, 128);
206
46
 
207
47
  if (!needle) return [];
208
48
  const maxDistance = Math.max(1, Math.floor(needle.length / 3));
209
49
  const scored = [];
210
50
 
211
51
  for (const candidate of candidates) {
212
- const lower = candidate.toLowerCase();
213
-
214
- if (lower === needle) continue;
215
- const distance = lower.includes(needle) || needle.includes(lower) ? 1 : editDistance(needle, lower);
52
+ const hit = scoreName(needle, candidate, maxDistance);
216
53
 
217
- if (distance <= maxDistance) scored.push({ candidate, distance });
54
+ if (hit) scored.push(hit);
218
55
  }
219
56
 
220
- scored.sort(
221
- (a, b) =>
222
- a.distance - b.distance ||
223
- Math.abs(a.candidate.length - needle.length) - Math.abs(b.candidate.length - needle.length) ||
224
- a.candidate.localeCompare(b.candidate),
225
- );
57
+ scored.sort((a, b) => bySuggestionRank(needle, a, b));
226
58
 
227
59
  return scored.slice(0, limit).map((s) => s.candidate);
228
60
  }
229
61
 
230
62
  export function unknownToolMessage(name, candidates) {
231
- const close = suggestNames(name, candidates);
63
+ const close = suggestNames(name, candidates.filter(isString));
232
64
  const hint = close.length ? ` Did you mean ${close.map((c) => JSON.stringify(c)).join(", ")}?` : "";
233
65
 
234
66
  return `unknown tool "${name}".${hint} Check the command name and configured tool exclusions.`;
235
67
  }
236
-
237
- export function describeTool(catalog, name) {
238
- const row = catalog.find((t) => t.name === name);
239
-
240
- if (!row) {
241
- return { ok: false, error: unknownToolMessage(name, catalog.map((t) => t.name)) };
242
- }
243
-
244
- if (!isObject(row.parameters)) return { ok: false, name, error: "tool schema unavailable: " + (row.schemaError ?? name) };
245
-
246
- if (!row._described) {
247
- row._described = {
248
- ok: true,
249
- name: row.name,
250
- description: row.description,
251
- parameters: row.parameters,
252
- sourcePath: row.sourcePath,
253
- };
254
- }
255
-
256
- return row._described;
257
- }