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
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import { isString } from "../shared/decode.js";
3
4
  import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
@@ -12,6 +13,63 @@ function textResult(text, details) {
12
13
  return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
13
14
  }
14
15
 
16
+ async function candidateFileList(index, root, includeHidden = false, signal) {
17
+ const stat = await fs.stat(root).catch(() => null);
18
+
19
+ if (stat?.isFile()) return [root];
20
+
21
+ return index.files(root, includeHidden, signal);
22
+ }
23
+
24
+ function pendingInScope(root, pendingPaths) {
25
+ return pendingPaths.filter(file => {
26
+ const relative = path.relative(root, file);
27
+
28
+ return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
29
+ });
30
+ }
31
+
32
+ function parseMatchRecord(line, truncated, isLast) {
33
+ if (!line) return { skip: true };
34
+
35
+ try { return { record: JSON.parse(line) }; }
36
+ catch (error) {
37
+ if (truncated && isLast) return { stop: true };
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ function isRgMatch(record) {
43
+ return record.type === "match" && isString(record.data?.path?.text) && isString(record.data.lines?.text);
44
+ }
45
+
46
+ function applyMatchRecord(record, root, overlayText, add) {
47
+ if (!isRgMatch(record)) return;
48
+ const file = path.resolve(root, record.data.path.text);
49
+
50
+ if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
51
+ }
52
+
53
+ function ingestRgMatches(records, result, signal, root, overlayText, add) {
54
+ for (let i = 0; i < records.length; i++) {
55
+ signal?.throwIfAborted();
56
+ const parsed = parseMatchRecord(records[i], result.outputTruncated, i === records.length - 1);
57
+
58
+ if (parsed.stop) break;
59
+
60
+ if (parsed.skip) continue;
61
+ applyMatchRecord(parsed.record, root, overlayText, add);
62
+ }
63
+ }
64
+
65
+ function ingestPendingRefs(root, pendingPaths, overlayText, add) {
66
+ for (const file of pendingInScope(root, pendingPaths)) {
67
+ const text = overlayText(file);
68
+
69
+ if (text !== undefined && Buffer.byteLength(text, "utf8") <= 512 * 1024) text.split("\n").forEach((line, i) => add(file, i + 1, line));
70
+ }
71
+ }
72
+
15
73
  /** One bounded direct search for all changed names; no repository index or per-name spawn. */
16
74
  export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
17
75
  const references = new Map(names.map(name => [name, []]));
@@ -31,57 +89,90 @@ export async function referencesForNames({ root, names, excludePath, overlayText
31
89
  { cwd: root, signal, timeoutMs: 5000, maxOutputChars: 65536 });
32
90
 
33
91
  if (result.exitCode !== 0 && result.exitCode !== 1) throw new Error(result.stderr.trim() || "reference search failed");
34
- const records = result.stdout.split("\n");
92
+ ingestRgMatches(result.stdout.split("\n"), result, signal, root, overlayText, add);
93
+ ingestPendingRefs(root, pendingPaths, overlayText, add);
35
94
 
36
- for (let i = 0; i < records.length; i++) {
37
- signal?.throwIfAborted();
95
+ return { references, incomplete: result.outputTruncated === true };
96
+ }
38
97
 
39
- if (!records[i]) continue;
40
- let record;
98
+ function grepCaseSensitive(pattern, params) {
99
+ return params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
100
+ }
41
101
 
42
- try { record = JSON.parse(records[i]); }
43
- catch (error) { if (result.outputTruncated && i === records.length - 1) break; throw error; }
102
+ function pushGrepFlags(args, pattern, params) {
103
+ if (!grepCaseSensitive(pattern, params)) args.push("--ignore-case");
44
104
 
45
- if (record.type !== "match" || !isString(record.data?.path?.text) || !isString(record.data.lines?.text)) continue;
46
- const file = path.resolve(root, record.data.path.text);
105
+ if (params?.glob) args.push("--glob", String(params.glob));
106
+ const limit = params?.limit;
47
107
 
48
- if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
49
- }
108
+ if (Number.isInteger(limit) && limit > 0) args.push("--max-count", String(Math.min(limit, 2000)));
109
+ }
50
110
 
51
- for (const file of pendingPaths) {
52
- const text = overlayText(file);
111
+ export function rgGrepArgs(pattern, params, searchPath) {
112
+ const args = ["--line-number", "--no-heading", "--color", "never"];
113
+ pushGrepFlags(args, pattern, params);
114
+ args.push("--", pattern, searchPath);
53
115
 
54
- if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
55
- }
116
+ return args;
117
+ }
56
118
 
57
- return { references, incomplete: result.outputTruncated === true };
119
+ function globMatcher(pattern) {
120
+ if (!pattern) return null;
121
+
122
+ try { return globToRegExp(pattern); }
123
+ catch { return /^$/; }
58
124
  }
59
125
 
60
- export function rgGrepArgs(pattern, params, searchPath) {
61
- const args = ["--line-number", "--no-heading", "--color", "never"];
126
+ function isDirectList(stat, pendingLength) {
127
+ return !!(stat?.isFile() || (!stat?.isDirectory() && pendingLength));
128
+ }
62
129
 
63
- if (params?.caseSensitive !== true) args.push("--ignore-case");
130
+ function listDirectRows(stat, searchDir, cwd, pending, matcher) {
131
+ const rel = stat?.isFile() ? relativeSlash(cwd, searchDir) : null;
64
132
 
65
- if (params?.glob) args.push("--glob", String(params.glob));
66
- args.push("--", pattern, searchPath);
133
+ return [...new Set([...(rel ? [rel] : []), ...pending])].filter(file => !matcher || matcher.test(file));
134
+ }
67
135
 
68
- return args;
136
+ function mergeListStdout(stdout, cwd, pendingMerged) {
137
+ const diskRows = String(stdout || "").split("\n").filter(Boolean)
138
+ .map(row => relativeSlash(cwd, path.isAbsolute(row) ? row : path.resolve(cwd, row)));
139
+ const rows = [...new Set([...diskRows, ...pendingMerged])];
140
+
141
+ return rows.length ? rows.join("\n") + "\n" : "";
69
142
  }
70
143
 
71
- /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
72
- export async function listWithTools(searchDir, pattern, cwd, signal) {
144
+ async function listDisk(searchDir, pattern, cwd, signal) {
73
145
  const args = ["--files"];
74
146
 
75
147
  if (pattern) args.push("-g", pattern);
76
148
  const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
77
149
 
78
- if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
150
+ if (res && (res.exitCode === 0 || res.exitCode === 1)) return { stdout: res.stdout, via: "rg", outputTruncated: res.outputTruncated === true };
79
151
  const findArgs = [searchDir];
80
152
 
81
153
  if (pattern) findArgs.push("-name", pattern);
82
154
  const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
83
155
 
84
- return textResult(findRes.stdout, { via: "find" });
156
+ return { stdout: findRes.stdout, via: "find", outputTruncated: findRes.outputTruncated === true };
157
+ }
158
+
159
+ /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
160
+ export async function listWithTools(searchDir, pattern, cwd, signal, pendingPaths = []) {
161
+ const stat = await fs.stat(searchDir).catch(() => null);
162
+ const pendingAbs = pendingInScope(searchDir, pendingPaths);
163
+ const pending = pendingAbs.map(file => relativeSlash(cwd, file));
164
+ const matcher = globMatcher(pattern);
165
+
166
+ if (isDirectList(stat, pending.length)) {
167
+ const rows = listDirectRows(stat, searchDir, cwd, pending, matcher);
168
+
169
+ return textResult(rows.length ? rows.join("\n") + "\n" : "", { via: pending.length ? "vfs" : "file" });
170
+ }
171
+
172
+ const pendingMerged = pendingAbs.filter((_, i) => !matcher || matcher.test(pending[i]));
173
+ const listed = await listDisk(searchDir, pattern, cwd, signal);
174
+
175
+ return textResult(mergeListStdout(listed.stdout, cwd, pendingMerged), { via: listed.via, outputTruncated: listed.outputTruncated });
85
176
  }
86
177
 
87
178
  const GLOB_CHARS = /[*?[\]{}]/;
@@ -90,9 +181,9 @@ const GLOB_CHARS = /[*?[\]{}]/;
90
181
  * fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
91
182
  * Returns "path" rows (best first) or null when the pattern is a real glob.
92
183
  */
93
- export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
184
+ export async function fuzzyFind(index, root, cwd, pattern, limit = 20, pendingPaths = []) {
94
185
  if (!pattern || GLOB_CHARS.test(pattern)) return null;
95
- const files = await index.files(root);
186
+ const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
96
187
 
97
188
  if (!index.canScan(files)) return null;
98
189
  const rel = files.map((f) => relativeSlash(cwd, f));
@@ -108,29 +199,43 @@ export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
108
199
  return rows.map((r) => r.path).join("\n") + "\n";
109
200
  }
110
201
 
202
+ function applyGlob(files, params, cwd) {
203
+ if (!params?.glob) return files;
204
+ const matcher = globToRegExp(String(params.glob));
205
+
206
+ return files.filter((f) => matcher.test(relativeSlash(cwd, f)));
207
+ }
208
+
209
+ function filesReadable(index, files, overlayText) {
210
+ for (const file of files) {
211
+ const overlay = overlayText(file);
212
+
213
+ if (overlay === undefined && index.entry(file) === null) return false;
214
+ }
215
+
216
+ return true;
217
+ }
218
+
111
219
  /** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
112
- export async function grepIndexed(index, pattern, params, searchPath, cwd) {
220
+ export async function grepIndexed(index, pattern, params, searchPath, cwd, overlayText = () => undefined, pendingPaths = []) {
113
221
  const compiled = grepRegex(pattern, params);
114
222
 
115
223
  if (!compiled) return null;
116
224
  const { regex, caseSensitive } = compiled;
117
- let files = await index.files(searchPath);
225
+ let files = [...new Set([...await candidateFileList(index, searchPath), ...pendingInScope(searchPath, pendingPaths)])];
118
226
 
119
227
  if (!index.canScan(files)) return null;
228
+ files = applyGlob(files, params, cwd);
120
229
 
121
- if (params?.glob) {
122
- const matcher = globToRegExp(String(params.glob));
123
- files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
124
- }
125
-
126
- const rows = index.grepRows(files, regex, cwd);
127
- const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
230
+ if (!filesReadable(index, files, overlayText)) return null;
231
+ const rows = index.grepRows(files, regex, cwd, overlayText);
232
+ const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText) : rows;
128
233
 
129
234
  return formatGrepRows(fallback, grepLimit(params));
130
235
  }
131
236
 
132
237
  function grepLimit(params) {
133
- return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
238
+ return Number.isInteger(params?.limit) && params.limit > 0 ? Math.min(params.limit, 2000) : 200;
134
239
  }
135
240
 
136
241
  function grepRegex(pattern, params) {
@@ -143,32 +248,45 @@ function grepRegex(pattern, params) {
143
248
  }
144
249
  }
145
250
 
251
+ function overlaySearchEntry(index, filePath, overlayText) {
252
+ const pending = overlayText(filePath);
253
+
254
+ return pending === undefined
255
+ ? index.entry(filePath)
256
+ : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
257
+ }
258
+
259
+ function fuzzyLineRow(pattern, rawLine, defName, rel, line, maxTypos, caseSensitive) {
260
+ const m = fuzzyMatch(pattern, rawLine, { maxTypos, caseSensitive });
261
+
262
+ if (!m || m.end - m.start > pattern.length + 2) return null;
263
+
264
+ return { rel, line, text: rawLine, def: defName !== "" && fuzzyMatch(pattern, defName, { maxTypos }) !== null };
265
+ }
266
+
146
267
  /** Zero literal hits: retry each line fuzzily (1 typo, 2 for long names) within a tight span, so IsOffTheRecord finds is_off_the_record. */
147
- function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
268
+ function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText = () => undefined) {
148
269
  const maxTypos = pattern.length >= 8 ? 2 : 1;
149
270
  const rows = [];
150
271
 
151
272
  for (const filePath of files) {
152
- const e = index.entry(filePath);
273
+ const e = overlaySearchEntry(index, filePath, overlayText);
153
274
 
154
275
  if (!e) continue;
155
276
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
156
277
  const rel = relativeSlash(cwd, filePath);
157
278
 
158
279
  for (let i = 0; i < raw.length && rows.length <= 400; i++) {
159
- const m = fuzzyMatch(pattern, raw[i], { maxTypos, caseSensitive });
280
+ const row = fuzzyLineRow(pattern, raw[i], defNames[i], rel, i + 1, maxTypos, caseSensitive);
160
281
 
161
- if (!m || m.end - m.start > pattern.length + 2) continue;
162
- rows.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && fuzzyMatch(pattern, defNames[i], { maxTypos }) !== null });
282
+ if (row) rows.push(row);
163
283
  }
164
284
  }
165
285
 
166
286
  return rows;
167
287
  }
168
288
 
169
- /** fff definition-first hinting: files that declare the name come first, declarations first within a file; one header per file. */
170
- function formatGrepRows(rows, limit) {
171
- if (rows.length === 0) return "";
289
+ function groupGrepRows(rows) {
172
290
  const groups = new Map();
173
291
 
174
292
  for (const r of rows) {
@@ -176,19 +294,34 @@ function formatGrepRows(rows, limit) {
176
294
  groups.get(r.rel).push(r);
177
295
  }
178
296
 
179
- const files = [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
297
+ return [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
298
+ }
299
+
300
+ function formatGroup(group, limit, shown) {
301
+ let out = group[0].rel + "\n";
302
+ group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
303
+ let n = shown;
304
+
305
+ for (const r of group) {
306
+ if (n++ >= limit) break;
307
+ out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
308
+ }
309
+
310
+ return { out, shown: n };
311
+ }
312
+
313
+ /** fff definition-first hinting: files that declare the name come first, declarations first within a file; one header per file. */
314
+ function formatGrepRows(rows, limit) {
315
+ if (rows.length === 0) return "";
316
+ const files = groupGrepRows(rows);
180
317
  let out = "";
181
318
  let shown = 0;
182
319
 
183
320
  for (const group of files) {
184
321
  if (shown >= limit) break;
185
- out += group[0].rel + "\n";
186
- group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
187
-
188
- for (const r of group) {
189
- if (shown++ >= limit) break;
190
- out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
191
- }
322
+ const next = formatGroup(group, limit, shown);
323
+ out += next.out;
324
+ shown = next.shown;
192
325
  }
193
326
 
194
327
  if (rows.length > limit) out += "… " + (rows.length - limit) + " more matches (pass limit or narrow the pattern)\n";
@@ -197,8 +330,8 @@ function formatGrepRows(rows, limit) {
197
330
  }
198
331
 
199
332
  /** rg --files [-g pattern] served from the index; null when the tree is too large. */
200
- export async function listIndexed(index, root, cwd, pattern) {
201
- const files = await index.files(root);
333
+ export async function listIndexed(index, root, cwd, pattern, pendingPaths = []) {
334
+ const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
202
335
 
203
336
  if (!index.canScan(files)) return null;
204
337
  const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));