pi-supernova 0.0.8 → 0.0.11

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.
package/host-bridge.js CHANGED
@@ -8,9 +8,11 @@ import { unknownToolMessage } from "./catalog.js";
8
8
  import { extractStructuralSurface } from "./surface.js";
9
9
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
10
10
  import { executeSnap } from "./snap.js";
11
+ import { selectEvidence } from "./evidence.js";
12
+ import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
11
13
  import { CausalVfs } from "./vfs.js";
12
14
  import { applyPatchToText } from "./patch.js";
13
- import { resolveWorkspacePath, runCommand } from "./workspace.js";
15
+ import { resolveWorkspacePath, runCommand, clearPathCache } from "./workspace.js";
14
16
 
15
17
  function textResult(text, details) {
16
18
  return {
@@ -113,7 +115,61 @@ async function formatLsEntry(dirPath, entry) {
113
115
  return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
114
116
  }
115
117
 
116
- function createNativeAdapters(getCwd, vfs, config) {
118
+ function rgGrepArgs(pattern, params, searchPath) {
119
+ const args = ["--line-number", "--no-heading", "--color", "never"];
120
+ if (params?.caseSensitive !== true) args.push("--ignore-case");
121
+ if (params?.glob) args.push("--glob", String(params.glob));
122
+ args.push("--", pattern, searchPath);
123
+ return args;
124
+ }
125
+
126
+ /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
127
+ async function listWithTools(searchDir, pattern, cwd, signal) {
128
+ const args = ["--files"];
129
+ if (pattern) args.push("-g", pattern);
130
+ const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
131
+ if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
132
+ const findArgs = [searchDir];
133
+ if (pattern) findArgs.push("-name", pattern);
134
+ const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
135
+ return textResult(findRes.stdout, { via: "find" });
136
+ }
137
+
138
+ /** rg-compatible grep served from the index; null when the pattern or tree needs real rg. */
139
+ async function grepIndexed(index, pattern, params, searchPath, cwd) {
140
+ let regex;
141
+ try {
142
+ regex = new RegExp(pattern, params?.caseSensitive === true ? "" : "i");
143
+ } catch {
144
+ return null;
145
+ }
146
+ let files = await index.files(searchPath);
147
+ if (!index.canScan(files)) return null;
148
+ if (params?.glob) {
149
+ const matcher = globToRegExp(String(params.glob));
150
+ files = files.filter((f) => matcher.test(path.relative(cwd, f).split(path.sep).join("/")));
151
+ }
152
+ const rows = index.grep(files, regex, cwd);
153
+ return rows.length ? rows.join("\n") + "\n" : "";
154
+ }
155
+
156
+ /** rg --files [-g pattern] served from the index; null when the tree is too large. */
157
+ async function listIndexed(index, root, cwd, pattern) {
158
+ const files = await index.files(root);
159
+ if (!index.canScan(files)) return null;
160
+ const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
161
+ if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
162
+ let matcher;
163
+ try {
164
+ matcher = globToRegExp(pattern);
165
+ } catch {
166
+ return null;
167
+ }
168
+ const hits = rel.filter((f) => matcher.test(f));
169
+ return hits.length ? hits.join("\n") + "\n" : "";
170
+ }
171
+
172
+ function createNativeAdapters(getCwd, vfs, config, index) {
117
173
  async function readAdapter(params, signal) {
118
174
  const cwd = getCwd();
119
175
  const targetParam = params?.path ?? params?.target;
@@ -143,8 +199,9 @@ function createNativeAdapters(getCwd, vfs, config) {
143
199
  const snapRes = await executeSnap({
144
200
  query: targetParam,
145
201
  searchDir: cwd,
146
- vfs,
147
- runCommand: (argv, opts) => runCommand(argv, { cwd, signal, ...opts }),
202
+ index,
203
+ overlayText: (p) => vfs.getOverlay(p),
204
+ pendingPaths: vfs.getOverlayPaths(),
148
205
  });
149
206
  return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
150
207
  } catch {}
@@ -243,19 +300,25 @@ function createNativeAdapters(getCwd, vfs, config) {
243
300
  const res = await executeSnap({
244
301
  query: params.query,
245
302
  searchDir: snapTarget,
303
+ root: cwd,
246
304
  includeHidden,
247
- vfs: {
248
- read: async (candidate) => {
249
- // Jail each snap candidate (symlink files must not escape the workspace).
250
- const jailed = await resolveWorkspacePath(cwd, candidate, "snap", false);
251
- return vfs.read(jailed);
252
- },
253
- },
254
- runCommand: (argv, opts) => runCommand(argv, { cwd: snapTarget, signal, ...opts }),
305
+ index,
306
+ overlayText: (p) => vfs.getOverlay(p),
255
307
  pendingPaths: vfs.getOverlayPaths(),
256
308
  });
257
309
  return textResult(JSON.stringify(res, null, 2), res);
258
310
  },
311
+ async evidence(params, signal) {
312
+ const cwd = getCwd();
313
+ if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
314
+ if (signal?.aborted) throw new Error("aborted");
315
+ const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
316
+ const options = {};
317
+ if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
318
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
319
+ const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), options });
320
+ return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
321
+ },
259
322
  async surface(params, signal) {
260
323
  const cwd = getCwd();
261
324
  const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
@@ -286,6 +349,7 @@ function createNativeAdapters(getCwd, vfs, config) {
286
349
  });
287
350
  } finally {
288
351
  vfs.invalidateCache();
352
+ index.invalidate();
289
353
  }
290
354
  const { stdout, stderr } = res;
291
355
  const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
@@ -300,11 +364,9 @@ function createNativeAdapters(getCwd, vfs, config) {
300
364
  const pattern = String(params?.pattern || "");
301
365
  if (!pattern) throw new Error("grep requires pattern");
302
366
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
303
- const args = ["--line-number", "--no-heading", "--color", "never"];
304
- if (params?.caseSensitive !== true) args.push("--ignore-case");
305
- if (params?.glob) args.push("--glob", String(params.glob));
306
- args.push("--", pattern, searchPath);
307
- const res = await runCommand(["rg", ...args], { cwd, timeoutMs: 30_000, signal });
367
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
368
+ if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
369
+ const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
308
370
  if (res.exitCode !== 0 && res.exitCode !== 1) {
309
371
  throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
310
372
  }
@@ -314,6 +376,8 @@ function createNativeAdapters(getCwd, vfs, config) {
314
376
  const cwd = getCwd();
315
377
  const pattern = String(params?.pattern || "");
316
378
  if (!pattern) throw new Error("glob requires pattern");
379
+ const indexed = await listIndexed(index, cwd, cwd, pattern);
380
+ if (indexed !== null) return textResult(indexed, { via: "index" });
317
381
  const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
318
382
  () => null,
319
383
  );
@@ -333,16 +397,10 @@ function createNativeAdapters(getCwd, vfs, config) {
333
397
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
334
398
  const pattern = params?.pattern || params?.glob;
335
399
  if (signal?.aborted) throw new Error("aborted");
336
- const args = ["--files"];
337
- if (pattern) args.push("-g", String(pattern));
338
- const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
339
- if (res && (res.exitCode === 0 || res.exitCode === 1)) {
340
- return textResult(res.stdout, { via: "rg" });
341
- }
342
- const findArgs = [searchDir];
343
- if (pattern) findArgs.push("-name", String(pattern));
344
- const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
345
- return textResult(findRes.stdout, { via: "find" });
400
+ const globPattern = pattern ? String(pattern) : null;
401
+ const indexed = await listIndexed(index, searchDir, cwd, globPattern);
402
+ if (indexed !== null) return textResult(indexed, { via: "index" });
403
+ return listWithTools(searchDir, globPattern, cwd, signal);
346
404
  },
347
405
  async ls(params, signal) {
348
406
  const cwd = getCwd();
@@ -359,9 +417,10 @@ function createNativeAdapters(getCwd, vfs, config) {
359
417
  }
360
418
 
361
419
  export function createHostBridge({ pi, config, getCwd }) {
362
- const vfs = new CausalVfs();
420
+ const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
421
+ const vfs = new CausalVfs(() => index.invalidate());
363
422
  const executors = new Map();
364
- const natives = createNativeAdapters(getCwd, vfs, config);
423
+ const natives = createNativeAdapters(getCwd, vfs, config, index);
365
424
  let callCount = 0;
366
425
  let activeCtx = null;
367
426
  let activeSignal = undefined;
@@ -395,6 +454,7 @@ export function createHostBridge({ pi, config, getCwd }) {
395
454
  trace = [];
396
455
  // Files may change between programs (editor, git); never serve a stale run.
397
456
  vfs.invalidateCache();
457
+ clearPathCache();
398
458
  }
399
459
 
400
460
  function getTrace() {
@@ -514,6 +574,7 @@ export function createHostBridge({ pi, config, getCwd }) {
514
574
  } catch (error) {
515
575
  record.ok = false;
516
576
  record.ms = Date.now() - record.time;
577
+ record.error = error instanceof Error ? error.message : String(error);
517
578
  notifyCall(record);
518
579
  throw error;
519
580
  }
package/index.js CHANGED
@@ -31,14 +31,44 @@ function result(text, details) {
31
31
  return { content: [{ type: "text", text }], details };
32
32
  }
33
33
 
34
- /** Live trace updates for the card; a throwing host callback must never break the run. */
34
+ const PROGRESS_FRAME_MS = 40;
35
+
36
+ /**
37
+ * Live trace updates for the card. The first update is immediate (seeds the result slot);
38
+ * later ones are coalesced to one host re-render per frame so a tight loop of nova.calls
39
+ * is not throttled by the TUI. A throwing host callback must never break the run.
40
+ */
35
41
  function progressEmitter(onUpdate) {
36
- if (!isFunction(onUpdate)) return () => {};
37
- return (trace) => {
42
+ if (!isFunction(onUpdate)) return Object.assign(() => {}, { flush() {} });
43
+ let pending = null;
44
+ let timer = null;
45
+ const send = (trace) => {
38
46
  try {
39
47
  onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
40
48
  } catch {}
41
49
  };
50
+ const flush = () => {
51
+ timer = null;
52
+ if (pending === null) return;
53
+ const trace = pending;
54
+ pending = null;
55
+ send(trace);
56
+ };
57
+ const emit = (trace) => {
58
+ if (timer === null && pending === null) {
59
+ send(trace);
60
+ timer = setTimeout(flush, PROGRESS_FRAME_MS);
61
+ return;
62
+ }
63
+ pending = trace;
64
+ if (timer === null) timer = setTimeout(flush, PROGRESS_FRAME_MS);
65
+ };
66
+ emit.flush = () => {
67
+ if (timer !== null) clearTimeout(timer);
68
+ pending = null;
69
+ timer = null;
70
+ };
71
+ return emit;
42
72
  }
43
73
 
44
74
  function logsBlock(outcome, tail = "") {
@@ -73,6 +103,7 @@ Globals (async):
73
103
  read(path|paths, offset?, limit?) → text | text[]
74
104
  write(path, text) · edit(path, oldText, newText) · patch(path, unifiedDiff)
75
105
  bash(cmd, {cwd?, timeoutMs?}) → output, throws on non-zero exit · exec(cmd, argv?) quotes argv
106
+ evidence(query, {k?}) → {spans: [{path, lines, name, text}]} top-K spans that answer a question — use before read
76
107
  snap(query, root?) → {path, line, signature, context} · surface(path) → {items: [{name, kind, line}]}
77
108
  nova.call(name, args) → {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
78
109
  nova.search(query) → [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
@@ -148,7 +179,7 @@ export default function piSupernova(pi) {
148
179
  description: TOOL_DESCRIPTION,
149
180
  promptSnippet: "Compose host tools in one JavaScript program",
150
181
  promptGuidelines: [
151
- "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. Return a compact shaped value; keep raw tool output inside the program.",
182
+ "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. To understand code, call evidence(question) and read only the returned spans; read whole files only to edit them. Return a compact shaped value; keep raw tool output inside the program.",
152
183
  ],
153
184
  parameters: Type.Object({
154
185
  code: Type.String({ description: "JavaScript program: async body or arrow function." }),
@@ -180,6 +211,7 @@ export default function piSupernova(pi) {
180
211
  outcome = await runProgram(params, runController.signal, abortRun);
181
212
  } finally {
182
213
  bridge.setCallListener(null);
214
+ emitProgress.flush();
183
215
  signal?.removeEventListener("abort", abortRun);
184
216
  }
185
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.8",
3
+ "version": "0.0.11",
4
4
  "description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -30,6 +30,8 @@
30
30
  "parallel.js",
31
31
  "runtime.js",
32
32
  "guest-worker.js",
33
+ "repo-index.js",
34
+ "evidence.js",
33
35
  "format.js",
34
36
  "patch.js",
35
37
  "vfs.js",
package/render.js CHANGED
@@ -178,7 +178,7 @@ function displayOperation(tool, target, diff, ok, item) {
178
178
  const rawName = cleanInlineText(tool);
179
179
  if (!rawName) return null;
180
180
  const normalized = rawName === "apply_patch" ? "patch" : rawName;
181
- return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time };
181
+ return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time, error: item?.error };
182
182
  }
183
183
 
184
184
  function isTheme(value) {
@@ -271,6 +271,11 @@ export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs
271
271
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
272
272
  }
273
273
 
274
+ function batchTarget(paths) {
275
+ const names = paths.map((p) => String(p).replace(/\\/g, "/").split("/").pop());
276
+ return `${paths.length} files: ${names.join(", ")}`;
277
+ }
278
+
274
279
  const OPERATION_TARGETS = [
275
280
  [(item) => item?.name === "snap", (item, args) => {
276
281
  const query = args.query ? `"${args.query}"` : "";
@@ -278,6 +283,7 @@ const OPERATION_TARGETS = [
278
283
  return `${query} → ${args.path}`;
279
284
  }],
280
285
  [(item) => item?.name === "search", (item, args) => (args.query ? `"${args.query}"` : "")],
286
+ [(item, args) => Array.isArray(args.path), (item, args) => batchTarget(args.path)],
281
287
  [(item, args) => args.path, (item, args) => String(args.path)],
282
288
  [(item) => item?.diff?.path, (item) => String(item.diff.path)],
283
289
  [(item, args) => args.target && isString(args.target), (item, args) => args.target],
@@ -405,8 +411,11 @@ function formatOpRow(theme, op, width, isPartial, isError) {
405
411
  prefix += theme.fg("toolDiffAdded", added) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", removed) + " ";
406
412
  used += added.length + 1 + removed.length + 1;
407
413
  }
408
- const target = formatTarget(op, Math.max(1, width - used));
409
- return prefix + (target ? theme.fg("muted", target) : theme.fg("dim", "done"));
414
+ const budget = Math.max(1, width - used);
415
+ const target = formatTarget(op, budget);
416
+ if (target) return prefix + theme.fg("muted", target);
417
+ if (op.ok === false && op.error) return prefix + theme.fg("error", clampLine(cleanInlineText(op.error), budget));
418
+ return prefix.trimEnd();
410
419
  }
411
420
 
412
421
  function operationsFor(payload, context, args) {
@@ -486,7 +495,8 @@ class UnifiedResultCard {
486
495
  }),
487
496
  sections: view.lines.length > 0 ? [{ lines: view.lines }] : [],
488
497
  state: model.isError ? "error" : model.isPartial ? "pending" : "success",
489
- borderColor: model.isError ? "error" : "borderMuted",
498
+ // borderMuted is invisible on OMP's card background; dim matches the duration column.
499
+ borderColor: model.isError ? "error" : "dim",
490
500
  width: frameWidth,
491
501
  };
492
502
  });
package/repo-index.js ADDED
@@ -0,0 +1,179 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { extractStructuralSurface } from "./surface.js";
4
+
5
+ // In-process workspace index: the gitignore-aware file list comes from one
6
+ // \`rg --files\` spawn and is then reused; file text, lowercase text, and the
7
+ // structural surface are cached per path and validated by mtime. snap/grep/glob
8
+ // read from here instead of spawning, so a warm call is sub-millisecond.
9
+
10
+ const LIST_TTL_MS = 10_000;
11
+ const MAX_INDEXED_FILES = 4000;
12
+ const MAX_FILE_BYTES = 512 * 1024;
13
+ const BINARY_EXT = new Set([
14
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
15
+ ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".wav", ".ogg", ".webm", ".wasm", ".class",
16
+ ".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".node", ".lock", ".sqlite", ".sqlite3", ".db",
17
+ ]);
18
+ const REGEX_SPECIAL = /[.+^${}()|\\]/g;
19
+ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
20
+ const EMPTY = Object.freeze([]);
21
+ const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
22
+
23
+ export function isTextCandidate(filePath) {
24
+ return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
25
+ }
26
+
27
+ /** Translate one glob token at index i → [regexSource, nextIndex]. */
28
+ function globToken(glob, i) {
29
+ const ch = glob[i];
30
+ if (ch === "*" && glob[i + 1] === "*") {
31
+ const slashAfter = glob[i + 2] === "/";
32
+ return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
33
+ }
34
+ if (ch === "*") return ["[^/]*", i + 1];
35
+ if (ch === "?") return ["[^/]", i + 1];
36
+ if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
37
+ return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
38
+ }
39
+
40
+ /** {a,b} alternation or [..] class starting at i. */
41
+ function globGroup(glob, i, open) {
42
+ const close = open === "{" ? "}" : "]";
43
+ const end = glob.indexOf(close, i);
44
+ if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
45
+ const inner = glob.slice(i + 1, end);
46
+ const source = open === "{" ? "(?:" + inner.split(",").map(globBody).join("|") + ")" : "[" + inner + "]";
47
+ return [source, end + 1];
48
+ }
49
+
50
+ function globBody(glob) {
51
+ let source = "";
52
+ let i = 0;
53
+ while (i < glob.length) {
54
+ const [piece, next] = globToken(glob, i);
55
+ source += piece;
56
+ i = next;
57
+ }
58
+ return source;
59
+ }
60
+
61
+ /** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
62
+ export function globToRegExp(glob) {
63
+ const body = globBody(glob);
64
+ return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
65
+ }
66
+
67
+ export class WorkspaceIndex {
68
+ constructor(runCommand) {
69
+ this.runCommand = runCommand;
70
+ this.lists = new Map();
71
+ this.entries = new Map();
72
+ }
73
+
74
+ invalidate() {
75
+ this.lists.clear();
76
+ }
77
+
78
+ /** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
79
+ async files(root, includeHidden = false) {
80
+ const key = root + "\0" + (includeHidden ? "h" : "");
81
+ const cached = this.lists.get(key);
82
+ if (cached && Date.now() - cached.at < LIST_TTL_MS) return cached.files;
83
+ const args = ["rg", "--files"];
84
+ if (includeHidden) args.push("--hidden");
85
+ args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
86
+ let files = [];
87
+ try {
88
+ const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000 });
89
+ files = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.resolve(root, f)).sort();
90
+ } catch {
91
+ files = [];
92
+ }
93
+ this.lists.set(key, { files, at: Date.now() });
94
+ return files;
95
+ }
96
+
97
+ /** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
98
+ entry(filePath) {
99
+ if (!isTextCandidate(filePath)) return null;
100
+ let stat;
101
+ try {
102
+ stat = fs.statSync(filePath);
103
+ } catch {
104
+ this.entries.delete(filePath);
105
+ return null;
106
+ }
107
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
108
+ const cached = this.entries.get(filePath);
109
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
110
+ let text;
111
+ try {
112
+ text = fs.readFileSync(filePath, "utf8");
113
+ } catch {
114
+ return null;
115
+ }
116
+ if (text.includes("\0")) return null;
117
+ const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined };
118
+ this.entries.set(filePath, created);
119
+ return created;
120
+ }
121
+
122
+ static fromText(filePath, text) {
123
+ return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined };
124
+ }
125
+
126
+ /** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens — computed once per entry. */
127
+ static linesOf(entry) {
128
+ if (entry.lines) return entry.lines;
129
+ const raw = entry.text.split("\n");
130
+ const lower = new Array(raw.length);
131
+ const defNames = new Array(raw.length);
132
+ const idents = new Array(raw.length);
133
+ for (let i = 0; i < raw.length; i++) {
134
+ const trimmed = raw[i].trim();
135
+ lower[i] = trimmed.toLowerCase();
136
+ defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
137
+ idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
138
+ }
139
+ entry.lines = { raw, lower, defNames, idents };
140
+ return entry.lines;
141
+ }
142
+
143
+ static surfaceOf(entry) {
144
+ if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
145
+ return entry.surface;
146
+ }
147
+
148
+ /** True when the list is small enough to scan in-process instead of spawning rg. */
149
+ canScan(files) {
150
+ return files.length <= MAX_INDEXED_FILES;
151
+ }
152
+
153
+ /** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
154
+ filesContaining(files, needles, anyOf) {
155
+ const hits = [];
156
+ for (const filePath of files) {
157
+ const e = this.entry(filePath);
158
+ if (!e) continue;
159
+ const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
160
+ if (found) hits.push(filePath);
161
+ }
162
+ return hits;
163
+ }
164
+
165
+ /** rg-style "path:line:text" rows over the indexed files, paths relative to root. */
166
+ grep(files, regex, root) {
167
+ const out = [];
168
+ for (const filePath of files) {
169
+ const e = this.entry(filePath);
170
+ if (!e || !regex.test(e.text)) continue;
171
+ const lines = e.text.split("\n");
172
+ const rel = path.relative(root, filePath) || filePath;
173
+ for (let i = 0; i < lines.length; i++) {
174
+ if (regex.test(lines[i])) out.push(rel + ":" + (i + 1) + ":" + lines[i]);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ }