pi-supernova 0.6.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 (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +104 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  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 +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  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 +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +289 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. package/src/ui/render.js +241 -145
@@ -0,0 +1,249 @@
1
+ import * as path from "node:path";
2
+ import { isString, isNumber } from "../shared/decode.js";
3
+ import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, MAX_DIFF_MATCHES } from "../fs/diff.js";
4
+ import { declaredName, WorkspaceIndex } from "../context/repo-index.js";
5
+ import { quickCheck } from "../fs/check.js";
6
+ import { applyPatchToText } from "../fs/patch.js";
7
+ import { resolveWorkspacePath, relativeSlash } from "../fs/workspace.js";
8
+ import { referencesForNames } from "../context/search.js";
9
+ import {
10
+ textResult, sourceLines, lineTextRange, applyReplacements, applyViewReplace,
11
+ shiftDiffLines, contentLineInfo, boundedEditDiff, QUICK_CHECK_MAX_CHARS,
12
+ } from "../fs/text-ops.js";
13
+
14
+ export function createEdit(ctx) {
15
+ const { getCwd, vfs, index, ledger } = ctx;
16
+ function lineAt(updated, newLines, n) {
17
+ if (newLines) return newLines[n - 1] ?? "";
18
+ const { start, end } = lineTextRange(updated, n);
19
+
20
+ return updated.slice(start, end).replace(/\r?\n$/, "");
21
+ }
22
+
23
+ function spanEditRange(span, lineCount) {
24
+ if (!span || !Number.isInteger(span.start) || !Number.isInteger(span.end) || span.start < 1 || span.end < span.start) return null;
25
+ const end = Math.min(lineCount, span.end);
26
+
27
+ return span.start <= end ? [{ start: span.start, end }] : [];
28
+ }
29
+
30
+ function mergeEditRange(ranges, line, lineCount) {
31
+ const start = Math.max(1, line - 2), end = Math.min(lineCount, line + 2);
32
+
33
+ if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
34
+ else ranges.push({ start, end });
35
+ }
36
+
37
+ function collectEditRanges(span, diff, lineCount) {
38
+ const explicit = spanEditRange(span, lineCount);
39
+
40
+ if (explicit) return explicit;
41
+ const ranges = [];
42
+ const positions = diff.lines.filter(row => row.type !== "context")
43
+ .map(row => Math.min(lineCount, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
44
+
45
+ for (const line of positions) mergeEditRange(ranges, line, lineCount);
46
+
47
+ return ranges;
48
+ }
49
+
50
+ function formatEditBlocks(rel, updated, newLines, ranges) {
51
+ const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
52
+ const blocks = [];
53
+
54
+ for (const { start, end } of ranges) {
55
+ const last = Math.min(end, start + perRange - 1);
56
+ const lines = Array.from({ length: Math.max(0, last - start + 1) }, (_, i) => lineAt(updated, newLines, start + i));
57
+ ledger.recordOrigin(rel, start, lines);
58
+ blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
59
+
60
+ if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
61
+ }
62
+
63
+ return blocks.join("\n");
64
+ }
65
+
66
+ async function editSummary(cwd, target, original, updated, diff, signal, span) {
67
+ const rel = relativeSlash(cwd, target);
68
+ const newLines = updated.length <= 512 * 1024 ? updated.split("\n") : null;
69
+ const lineCount = newLines ? newLines.length : contentLineInfo(updated).count;
70
+ let out = formatEditBlocks(rel, updated, newLines, collectEditRanges(span, diff, lineCount));
71
+
72
+ if (diff?.omittedMatches) out += `\n…${diff.omittedMatches} more matches (receipt shows the first ${MAX_DIFF_MATCHES})`;
73
+ const check = updated.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(updated, path.extname(target)) : null;
74
+
75
+ if (check && !check.ok) out += `\ncheck: ${check.message}`;
76
+ const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
77
+
78
+ if (refs) out += `\n${refs}`;
79
+
80
+ return out;
81
+ }
82
+
83
+ function lineText(text, lines, number) {
84
+ if (lines) return lines[number - 1] ?? "";
85
+ const { start, end } = lineTextRange(text, number);
86
+
87
+ return text.slice(start, end).replace(/\r?\n$/, "");
88
+ }
89
+
90
+ function nameAtDiffLine(l, original, updated, oldLines, newLines, canMapOwners, target, spans) {
91
+ const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
92
+ const source = l.type === "remove" ? original : updated;
93
+ const cached = l.type === "remove" ? oldLines : newLines;
94
+ const name = declaredName(lineText(source, cached, number));
95
+
96
+ if (name) return name;
97
+ if (!canMapOwners) return;
98
+ if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, source)));
99
+
100
+ return spans.get(l.type).find(span => span.start <= number && number <= span.end)?.name;
101
+ }
102
+
103
+ function collectChangedNames(target, original, updated, diff) {
104
+ const canMapOwners = original.length <= 512 * 1024 && updated.length <= 512 * 1024;
105
+ const oldLines = canMapOwners ? original.split("\n") : null;
106
+ const newLines = canMapOwners ? updated.split("\n") : null;
107
+ const names = new Set();
108
+ const spans = new Map();
109
+
110
+ for (const l of diff.lines) {
111
+ if (l.type === "context") continue;
112
+ const name = nameAtDiffLine(l, original, updated, oldLines, newLines, canMapOwners, target, spans);
113
+
114
+ if (name) names.add(name);
115
+ if (names.size >= 3) break;
116
+ }
117
+
118
+ return names;
119
+ }
120
+
121
+ function formatNameRefs(references, incomplete) {
122
+ const parts = [];
123
+
124
+ for (const [name, refs] of references) {
125
+ if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
126
+ }
127
+
128
+ if (incomplete) parts.push("references incomplete: search budget reached");
129
+
130
+ return parts.join("\n");
131
+ }
132
+
133
+ async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
134
+ const names = collectChangedNames(target, original, updated, diff);
135
+
136
+ if (names.size === 0) return "";
137
+
138
+ try {
139
+ const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
140
+ excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
141
+
142
+ return formatNameRefs(references, incomplete);
143
+ } catch (error) {
144
+ signal?.throwIfAborted();
145
+
146
+ return "references unavailable: " + error.message;
147
+ }
148
+ }
149
+
150
+ async function commitEdit(cwd, target, original, updated, diff, signal, span) {
151
+ const { speculative } = await vfs.write(target, updated);
152
+ index.touch(relativeSlash(cwd, target));
153
+ const summary = await editSummary(cwd, target, original, updated, diff, signal, span);
154
+
155
+ return textResult(summary, { path: target, speculative, diff });
156
+ }
157
+
158
+ async function applyViewEdit(cwd, target, content, params, signal) {
159
+ const viewText = String(params.viewText);
160
+ const nextText = String(params.newText);
161
+ const windowNext = isString(params.oldText)
162
+ ? applyReplacements(target, viewText, [{ oldText: String(params.oldText), newText: nextText }]).updated
163
+ : nextText;
164
+ const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, viewText, windowNext);
165
+ const diffFrom = isString(params.oldText) ? String(params.oldText) : viewText;
166
+ const diff = shiftDiffLines(buildEditDiff(target, viewText, diffFrom, isString(params.oldText) ? nextText : windowNext), params.viewStart - 1);
167
+ const spanEnd = params.viewStart + Math.max(sourceLines(windowNext).length, 1) - 1;
168
+
169
+ return commitEdit(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
170
+ }
171
+
172
+ function diffForMatches(target, content, updated, matches) {
173
+ if (content.length > 512 * 1024 || updated.length > 512 * 1024) return boundedEditDiff(target, content, matches);
174
+ if (matches.length === 1) return buildEditDiff(target, content, matches[0].oldText, matches[0].newText);
175
+
176
+ return buildMultiEditDiff(target, content, matches);
177
+ }
178
+
179
+ async function edit(params, signal) {
180
+ const cwd = getCwd();
181
+ const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
182
+
183
+ if (signal?.aborted) throw new Error("aborted");
184
+ const content = await vfs.read(target, { maxBytes: 64 * 1024 * 1024 });
185
+
186
+ if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
187
+ return applyViewEdit(cwd, target, content, params, signal);
188
+ }
189
+
190
+ const requestedEdits = Array.isArray(params?.edits) ? params.edits : [{ oldText: params?.oldText, newText: params?.newText }];
191
+ const { updated, matches } = applyReplacements(target, content, requestedEdits);
192
+
193
+ return commitEdit(cwd, target, content, updated, diffForMatches(target, content, updated, matches), signal);
194
+ }
195
+
196
+ function patchInputPath(params) {
197
+ let inputPath = params?.path;
198
+
199
+ if (!inputPath && isString(params?.patch)) {
200
+ for (const match of params.patch.matchAll(/^(?:---|\+\+\+)\s+([^\t\n]+)/gm)) {
201
+ const candidate = match[1].trim().replace(/^[ab]\//, "");
202
+
203
+ if (candidate !== "/dev/null") return candidate;
204
+ }
205
+ }
206
+
207
+ return inputPath;
208
+ }
209
+
210
+ async function readPatchOriginal(target) {
211
+ try {
212
+ return await vfs.read(target, { maxBytes: 64 * 1024 * 1024, preserveRead: true });
213
+ } catch (error) {
214
+ if (error?.code !== "ENOENT") throw error;
215
+
216
+ return "";
217
+ }
218
+ }
219
+
220
+ async function apply_patch(params, signal) {
221
+ const cwd = getCwd();
222
+ const target = await resolveWorkspacePath(cwd, patchInputPath(params), "apply_patch", false);
223
+
224
+ if (!isString(params?.patch) || !params.patch.trim()) {
225
+ throw new Error("apply_patch requires patch");
226
+ }
227
+
228
+ if (signal?.aborted) throw new Error("aborted");
229
+ const original = await readPatchOriginal(target);
230
+ if (original.length > 2 * 1024 * 1024) throw new Error("apply_patch input exceeds 2 MiB; use edit() for targeted replacements");
231
+ const { resultText, hunkCount, relocations } = applyPatchToText(original, params.patch);
232
+ const { speculative } = await vfs.write(target, resultText);
233
+ const diff = buildPatchDiff(target, params.patch, relocations);
234
+ index.touch(relativeSlash(cwd, target));
235
+ let summary = await editSummary(cwd, target, original, resultText, diff, signal);
236
+
237
+ if (relocations.length) summary += "\nrelocated " + relocations.map(entry => "#" + entry.hunk + " " + (entry.offset > 0 ? "+" : "") + entry.offset + " lines").join(", ");
238
+
239
+ return textResult(summary, {
240
+ path: target,
241
+ hunks: hunkCount,
242
+ speculative,
243
+ diff,
244
+ relocated: relocations,
245
+ });
246
+ }
247
+
248
+ return { edit, apply_patch, editSummary };
249
+ }
@@ -0,0 +1,31 @@
1
+ export const IMAGE_MAX_BYTES = 20 * 1024 * 1024;
2
+ export const LARGE_FILE_BYTES = 512 * 1024;
3
+
4
+ /** Raw path-only reads above these must use json/about/offset/complete. */
5
+ export const RAW_JSON_CHARS = 4096;
6
+
7
+ export const RAW_SOURCE_CHARS = 8192;
8
+
9
+ export const RAW_SOURCE_LINES = 160;
10
+
11
+ /** Routing responses above this fall back to the bound error instead of dumping. */
12
+ export const ROUTING_MAX_CHARS = 4096;
13
+ export const ABOUT_TOKEN_MAX = 16;
14
+ export const IMAGE_MIME = {
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".bmp": "image/bmp",
21
+ };
22
+
23
+ export function imageTooLarge(rel, size) {
24
+ return new Error("image " + rel + " is " + size + " bytes (" + (size / 1024 / 1024).toFixed(1) + " MiB); the image read limit is " + IMAGE_MAX_BYTES + " bytes (20 MiB); resize or select fewer/smaller images");
25
+ }
26
+
27
+ export function missingFile(targetPath) {
28
+ const error = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question)");
29
+ error.code = "ENOENT";
30
+ return error;
31
+ }
@@ -0,0 +1,31 @@
1
+ import { createNativeScheduler } from "../runtime/parallel.js";
2
+ import { createRead } from "./read.js";
3
+ import { createWrite } from "./write.js";
4
+ import { createEdit } from "./edit.js";
5
+ import { createBash } from "./bash.js";
6
+ import { createList } from "./list.js";
7
+
8
+ export function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
9
+ const ctx = { getCwd, vfs, config, index, ledger, hooks, reads: createNativeScheduler() };
10
+ const read = createRead(ctx);
11
+ ctx.readDirectory = read.readDirectory;
12
+ const write = createWrite(ctx);
13
+ const edit = createEdit(ctx);
14
+ const bash = createBash(ctx);
15
+ const list = createList(ctx);
16
+ hooks.summarizeEdit = edit.editSummary;
17
+ return {
18
+ read: read.read,
19
+ write: write.write,
20
+ edit: edit.edit,
21
+ apply_patch: edit.apply_patch,
22
+ snap: read.snap,
23
+ evidence: read.evidence,
24
+ surface: read.surface,
25
+ bash: bash.bash,
26
+ grep: list.grep,
27
+ glob: list.glob,
28
+ find: list.find,
29
+ ls: list.ls,
30
+ };
31
+ }
@@ -0,0 +1,102 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { resolveWorkspacePath, runCommand } from "../fs/workspace.js";
4
+ import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs } from "../context/search.js";
5
+ import { textResult, formatDirectoryEntry } from "../fs/text-ops.js";
6
+
7
+ function rawListPattern(op, params) {
8
+ if (op === "glob") return String(params?.pattern || "");
9
+
10
+ return params?.pattern || params?.glob;
11
+ }
12
+
13
+ function globPatternOf(op, params) {
14
+ const pattern = rawListPattern(op, params);
15
+
16
+ if (op === "glob" && !pattern) throw new Error("glob requires pattern");
17
+
18
+ return pattern ? String(pattern) : null;
19
+ }
20
+
21
+ async function resolveListDir(op, params, cwd) {
22
+ if (op === "find" && params?.path) return resolveWorkspacePath(cwd, params.path, op, true);
23
+
24
+ return cwd;
25
+ }
26
+
27
+ function fileListing(dirPath, size) {
28
+ const entry = formatDirectoryEntry(path.basename(dirPath), "file", size);
29
+
30
+ return textResult(entry, { path: dirPath, directory: false, count: 1, entries: [entry] });
31
+ }
32
+
33
+ export function createList(ctx) {
34
+ const { getCwd, vfs, index } = ctx;
35
+
36
+ async function listFromCache(searchDir, cwd, globPattern, pending) {
37
+ const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern, 20, pending);
38
+
39
+ if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
40
+ const indexed = await listIndexed(index, searchDir, cwd, globPattern, pending);
41
+
42
+ if (indexed !== null) return textResult(indexed, { via: "index" });
43
+
44
+ return null;
45
+ }
46
+
47
+ async function listFiles(params, signal, op, cwd) {
48
+ if (signal?.aborted) throw new Error("aborted");
49
+ const searchDir = await resolveListDir(op, params, cwd);
50
+ const globPattern = globPatternOf(op, params);
51
+ const pending = vfs.getOverlayPaths();
52
+ const cached = await listFromCache(searchDir, cwd, globPattern, pending);
53
+
54
+ if (cached !== null) return cached;
55
+
56
+ return listWithTools(searchDir, globPattern, cwd, signal, pending);
57
+ }
58
+
59
+ async function grepWithRg(pattern, params, searchPath, cwd, signal) {
60
+ const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
61
+
62
+ if (res.exitCode !== 0 && res.exitCode !== 1) {
63
+ throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
64
+ }
65
+
66
+ return textResult(res.stdout, { exitCode: res.exitCode });
67
+ }
68
+
69
+ async function grep(params, signal) {
70
+ const cwd = getCwd();
71
+ const pattern = String(params?.pattern || "");
72
+
73
+ if (!pattern) throw new Error("grep requires pattern");
74
+ const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
75
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd, file => vfs.getOverlay(file), vfs.getOverlayPaths());
76
+
77
+ if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
78
+ // Large tree: real rg keeps its own output format.
79
+ return grepWithRg(pattern, params, searchPath, cwd, signal);
80
+ }
81
+
82
+ async function glob(params, signal) {
83
+ return listFiles(params, signal, "glob", getCwd());
84
+ }
85
+ async function find(params, signal) {
86
+ return listFiles(params, signal, "find", getCwd());
87
+ }
88
+ async function ls(params, signal) {
89
+ const cwd = getCwd();
90
+ const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
91
+ const pending = vfs.getOverlay(dirPath);
92
+
93
+ if (pending !== undefined) return fileListing(dirPath, Buffer.byteLength(pending, "utf8"));
94
+ const stat = await fs.stat(dirPath).catch(() => null);
95
+
96
+ if (stat?.isFile()) return fileListing(dirPath, stat.size);
97
+
98
+ return ctx.readDirectory(dirPath, signal);
99
+ }
100
+
101
+ return { grep, glob, find, ls };
102
+ }