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
@@ -39,19 +39,23 @@ function newHistory() {
39
39
  // Experimental candidate collection, not proof of provider-visible retention:
40
40
  // AgentMessage metadata and later context transformations can hide these strings.
41
41
  // Keep this optimization opt-in until exact outgoing citation targets are validated.
42
- function collectRetained(value, retained, depth = 0) {
43
- if (isString(value)) {
44
- // Every line, not only substantive ones. A run may span a blank or short line,
45
- // so isRetained must be exact per line or the run truncates there. An oversized
46
- // line is never stored: it cannot sit inside a six-line run, and this is what
47
- // keeps base64 image payloads out of the retention set.
48
- if (value.length > MAX_OBSERVED_LINE && !value.includes("\n")) return;
42
+ function retainString(value, retained) {
43
+ // Every line, not only substantive ones. A run may span a blank or short line,
44
+ // so isRetained must be exact per line or the run truncates there. An oversized
45
+ // line is never stored: it cannot sit inside a six-line run, and this is what
46
+ // keeps base64 image payloads out of the retention set.
47
+ if (value.length > MAX_OBSERVED_LINE && !value.includes("\n")) return;
49
48
 
50
- for (const line of value.split("\n")) {
51
- if (retained.size >= MAX_STORED_LINES) return;
49
+ for (const line of value.split("\n")) {
50
+ if (retained.size >= MAX_STORED_LINES) return;
52
51
 
53
- if (line.length <= MAX_OBSERVED_LINE) retained.add(line);
54
- }
52
+ if (line.length <= MAX_OBSERVED_LINE) retained.add(line);
53
+ }
54
+ }
55
+
56
+ function collectRetained(value, retained, depth = 0) {
57
+ if (isString(value)) {
58
+ retainString(value, retained);
55
59
 
56
60
  return;
57
61
  }
@@ -174,7 +178,7 @@ export class SeenLedger {
174
178
  return count;
175
179
  }
176
180
 
177
- longestRun(lines, hashes, index, call) {
181
+ bestCandidateRun(lines, hashes, index, call) {
178
182
  const candidates = this.occurrences.get(hashes[index]);
179
183
 
180
184
  if (!candidates) return null;
@@ -186,12 +190,23 @@ export class SeenLedger {
186
190
  if (length >= MIN_RUN && (!best || length > best.length)) best = { ...candidate, length };
187
191
  }
188
192
 
189
- if (!best) return null;
193
+ return best;
194
+ }
195
+
196
+ substantiveCount(lines, index, length) {
190
197
  let count = 0;
191
198
 
192
- for (let i = index; i < index + best.length; i++) if (collapsible(lines[i])) count++;
199
+ for (let i = index; i < index + length; i++) if (collapsible(lines[i])) count++;
193
200
 
194
- return count >= MIN_SUBSTANTIVE ? best : null;
201
+ return count;
202
+ }
203
+
204
+ longestRun(lines, hashes, index, call) {
205
+ const best = this.bestCandidateRun(lines, hashes, index, call);
206
+
207
+ if (!best) return null;
208
+
209
+ return this.substantiveCount(lines, index, best.length) >= MIN_SUBSTANTIVE ? best : null;
195
210
  }
196
211
 
197
212
  citation(lines, index, run) {
@@ -245,19 +260,14 @@ export class SeenLedger {
245
260
  return sent;
246
261
  }
247
262
 
248
- remember(call, lines) {
249
- if (this.window === 0 || call <= this.history.latestCall - this.window || lines.length > MAX_STORED_LINES) return;
250
-
251
- if (this.results.has(call)) this.forget(call);
252
-
263
+ evictForCapacity(incoming) {
253
264
  for (const old of [...this.results.keys()].sort((a, b) => a - b)) {
254
- if (this.storedLines + lines.length <= MAX_STORED_LINES) break;
265
+ if (this.storedLines + incoming <= MAX_STORED_LINES) break;
255
266
  this.forget(old);
256
267
  }
268
+ }
257
269
 
258
- const hashes = Uint32Array.from(lines, hashLine);
259
- const origins = lines.map(line => this.origins.get(line));
260
-
270
+ indexCollapsible(lines, hashes, call) {
261
271
  for (let i = 0; i < lines.length; i++) {
262
272
  if (!collapsible(lines[i])) continue;
263
273
  let list = this.occurrences.get(hashes[i]);
@@ -265,7 +275,16 @@ export class SeenLedger {
265
275
  if (!list) this.occurrences.set(hashes[i], (list = []));
266
276
  list.push({ call, index: i });
267
277
  }
278
+ }
279
+
280
+ remember(call, lines) {
281
+ if (this.window === 0 || call <= this.history.latestCall - this.window || lines.length > MAX_STORED_LINES) return;
268
282
 
283
+ if (this.results.has(call)) this.forget(call);
284
+ this.evictForCapacity(lines.length);
285
+ const hashes = Uint32Array.from(lines, hashLine);
286
+ const origins = lines.map(line => this.origins.get(line));
287
+ this.indexCollapsible(lines, hashes, call);
269
288
  this.results.set(call, { hashes, lines, origins });
270
289
  this.history.storedLines += lines.length;
271
290
  }
@@ -28,6 +28,7 @@ function relevance(span, lower, stems) {
28
28
  }
29
29
 
30
30
  function chooseExpanded(spans, lower, stems, raw, opts) {
31
+ if (stems.length === 0) return new Set();
31
32
  const scored = spans.map((s, i) => ({ i, r: relevance(s, lower, stems), chars: raw.slice(s.start - 1, s.end).join("\n").length }));
32
33
  scored.sort((a, b) => b.r - a.r || a.i - b.i);
33
34
  const expanded = new Set();
@@ -96,16 +97,7 @@ function focusedText(raw, lower, stems, relPath, opts) {
96
97
  * @param entry index entry (text + cached lines/surface)
97
98
  * @param about question or symbol; empty ⇒ pure skeleton (every body folded)
98
99
  */
99
- export function outlineFile(entry, relPath, about, options = {}) {
100
- const opts = { ...OUTLINE_DEFAULTS, ...options };
101
- const { raw, lower } = WorkspaceIndex.linesOf(entry);
102
- const lineCount = raw.length;
103
- const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
104
- const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
105
-
106
- if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
107
- const expanded = chooseExpanded(spans, lower, stems, raw, opts);
108
-
100
+ function outlineHeader(spans, raw, opts) {
109
101
  const parts = [];
110
102
  const headerEnd = Math.min(spans[0].start - 1, opts.headerLines);
111
103
 
@@ -117,8 +109,30 @@ export function outlineFile(entry, relPath, about, options = {}) {
117
109
  if (spans[0].start - 1 > opts.headerLines) parts.push(" … " + (spans[0].start - 1 - opts.headerLines) + " more header lines");
118
110
  }
119
111
 
112
+ return parts;
113
+ }
114
+
115
+ function clipOutline(text, title, maxChars) {
116
+ if (text.length <= maxChars) return text;
117
+ const end = text.lastIndexOf("\n", Math.max(0, maxChars - 160));
118
+
119
+ return (end > title.length ? text.slice(0, end) : text.slice(0, maxChars)) + "\n … outline truncated; use read(path, line, count) for later declarations";
120
+ }
121
+
122
+ export function outlineFile(entry, relPath, about, options = {}) {
123
+ const opts = { ...OUTLINE_DEFAULTS, ...options };
124
+ const { raw, lower } = WorkspaceIndex.linesOf(entry);
125
+ const lineCount = raw.length;
126
+ const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
127
+ const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
128
+
129
+ if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
130
+ const expanded = chooseExpanded(spans, lower, stems, raw, opts);
131
+ const parts = outlineHeader(spans, raw, opts);
132
+
120
133
  for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
121
- const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (about ? " for \"" + about + "\"" : "") + " · read(path, line, count) for a folded body";
134
+ const label = about ? String(about).replace(/\s+/g, " ").slice(0, 120) : "";
135
+ const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (label ? " for \"" + label + "\"" : "") + " · read(path, line, count) for a folded body";
122
136
 
123
- return { text: title + "\n" + parts.join("\n"), expanded: expanded.size, declarations: spans.length };
137
+ return { text: clipOutline(title + "\n" + parts.join("\n"), title, opts.maxChars), expanded: expanded.size, declarations: spans.length };
124
138
  }
@@ -21,6 +21,7 @@ const WATCH_DEBOUNCE_MS = 150;
21
21
  const MAX_INDEXED_FILES = 4000;
22
22
 
23
23
  const MAX_FILE_BYTES = 512 * 1024;
24
+ const MAX_ENTRY_CACHE_BYTES = 64 * 1024 * 1024;
24
25
 
25
26
  const BINARY_EXT = new Set([
26
27
  ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
@@ -34,11 +35,13 @@ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
34
35
 
35
36
  const EMPTY = Object.freeze([]);
36
37
 
37
- 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_$]+)/;
38
+ 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_$]+)|([A-Z][A-Z0-9_$]*)\s*(?::[^=\n]+)?=)/;
38
39
 
39
- /** Declared identifier on a line (function/class/const/…), or ""; the same rule snap and grep use. */
40
+ /** Declared identifier on a line (function/class/UPPER_CASE constant/…), or ""; the same rule snap and grep use. */
40
41
  export function declaredName(line) {
41
- return DEF_PATTERN.exec(String(line).trim())?.[2] ?? "";
42
+ const match = DEF_PATTERN.exec(String(line).trim());
43
+
44
+ return match?.[2] ?? match?.[3] ?? "";
42
45
  }
43
46
 
44
47
  function isTextCandidate(filePath) {
@@ -71,7 +74,9 @@ function globGroup(glob, i, open) {
71
74
 
72
75
  if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
73
76
  const inner = glob.slice(i + 1, end);
74
- const source = open === "{" ? "(?:" + inner.split(",").map(globBody).join("|") + ")" : "[" + inner + "]";
77
+ const source = open === "{"
78
+ ? "(?:" + inner.split(",").map(globBody).join("|") + ")"
79
+ : "[" + (inner.startsWith("!") ? "^" + inner.slice(1) : inner) + "]";
75
80
 
76
81
  return [source, end + 1];
77
82
  }
@@ -96,35 +101,41 @@ export function globToRegExp(glob) {
96
101
  return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
97
102
  }
98
103
 
99
- function declarationEnd(raw, lower, start, lineCount, ext) {
100
- if (ext === ".py") {
101
- const indentOf = (i) => raw[i].length - raw[i].trimStart().length;
102
- const base = indentOf(start - 1);
103
- let end = start;
104
-
105
- for (let i = start; i < lineCount; i++) {
106
- if (lower[i] === "") { end = i + 1; continue; }
107
- if (indentOf(i) <= base) break;
108
- end = i + 1;
109
- }
104
+ function lineIndent(raw, i) {
105
+ return raw[i].length - raw[i].trimStart().length;
106
+ }
110
107
 
111
- return Math.min(end, lineCount);
108
+ function pythonDeclarationEnd(raw, lower, start, lineCount) {
109
+ const base = lineIndent(raw, start - 1);
110
+ let end = start;
111
+
112
+ for (let i = start; i < lineCount; i++) {
113
+ if (lower[i] === "") { end = i + 1; continue; }
114
+ if (lineIndent(raw, i) <= base) break;
115
+ end = i + 1;
112
116
  }
113
117
 
118
+ return Math.min(end, lineCount);
119
+ }
120
+
121
+ function braceDelta(text) {
114
122
  let depth = 0;
115
123
 
116
- for (const ch of raw[start - 1] ?? "") {
124
+ for (const ch of text) {
117
125
  if (ch === "{") depth++;
118
126
  else if (ch === "}") depth--;
119
127
  }
120
128
 
129
+ return depth;
130
+ }
131
+
132
+ function braceDeclarationEnd(raw, start, lineCount) {
133
+ let depth = braceDelta(raw[start - 1] ?? "");
134
+
121
135
  if (depth <= 0) return start;
122
136
 
123
137
  for (let i = start; i < raw.length; i++) {
124
- for (const ch of raw[i]) {
125
- if (ch === "{") depth++;
126
- else if (ch === "}") depth--;
127
- }
138
+ depth += braceDelta(raw[i]);
128
139
 
129
140
  if (depth <= 0) return i + 1;
130
141
  }
@@ -132,11 +143,95 @@ function declarationEnd(raw, lower, start, lineCount, ext) {
132
143
  return lineCount;
133
144
  }
134
145
 
146
+ function declarationEnd(raw, lower, start, lineCount, ext) {
147
+ if (ext === ".py") return pythonDeclarationEnd(raw, lower, start, lineCount);
148
+
149
+ return braceDeclarationEnd(raw, start, lineCount);
150
+ }
151
+
152
+ function readFdBuffer(fd, buffer) {
153
+ let offset = 0;
154
+
155
+ while (offset < buffer.length) {
156
+ const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
157
+
158
+ if (read <= 0) break;
159
+ offset += read;
160
+ }
161
+
162
+ return offset;
163
+ }
164
+
165
+ function parseRgFiles(res, root) {
166
+ let error;
167
+
168
+ if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
169
+ const truncated = res.outputTruncated === true;
170
+ const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
171
+
172
+ return { files: output.split("\n").flatMap(f => f ? [path.resolve(root, f)] : []).sort(), error, truncated, missing: false };
173
+ }
174
+
175
+ function addPorcelainRow(rows, i, set) {
176
+ const row = rows[i];
177
+
178
+ if (row.length <= 3) return i;
179
+ const status = row.slice(0, 2);
180
+ const file = row.slice(3);
181
+
182
+ if (file) set.add(file);
183
+
184
+ if ((status.includes("R") || status.includes("C")) && i + 1 < rows.length) {
185
+ const target = rows[i + 1];
186
+
187
+ if (target) set.add(target);
188
+
189
+ return i + 1;
190
+ }
191
+
192
+ return i;
193
+ }
194
+
195
+ function ingestPorcelain(stdout, set) {
196
+ const rows = stdout.split("\0");
197
+
198
+ for (let i = 0; i < rows.length; i++) i = addPorcelainRow(rows, i, set);
199
+ }
200
+
201
+ function grepPendingHuge(pending, rel, regex, out) {
202
+ let start = 0;
203
+ let line = 0;
204
+
205
+ while (start <= pending.length) {
206
+ const end = pending.indexOf("\n", start);
207
+ const stop = end === -1 ? pending.length : end;
208
+ const text = pending.slice(start, stop).replace(/\r$/, "");
209
+
210
+ line++;
211
+ if (regex.test(text)) out.push({ rel, line, text, def: false });
212
+ if (end === -1) break;
213
+ start = end + 1;
214
+ }
215
+ }
216
+
217
+ function grepEntryRows(e, filePath, root, regex, nameRegex, out) {
218
+ const lineAnchored = /\^|\$/.test(regex.source.replace(/\\[\^$]|\[[^\]]*\]/g, ""));
219
+
220
+ if (!lineAnchored && !regex.test(e.text)) return;
221
+ const { raw, defNames } = WorkspaceIndex.linesOf(e);
222
+ const rel = relativeSlash(root, filePath);
223
+
224
+ for (let i = 0; i < raw.length; i++) {
225
+ if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
226
+ }
227
+ }
228
+
135
229
  export class WorkspaceIndex {
136
230
  constructor(runCommand) {
137
231
  this.runCommand = runCommand;
138
232
  this.lists = new Map();
139
233
  this.entries = new Map();
234
+ this.entryBytes = 0;
140
235
  this.watchers = new Map();
141
236
  this.frecency = new Frecency();
142
237
  this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
@@ -145,6 +240,11 @@ export class WorkspaceIndex {
145
240
 
146
241
  invalidate() {
147
242
  this.lists.clear();
243
+ this.gitModified.clear();
244
+
245
+ for (const entry of this.entries.values()) this.entryBytes -= entry.weight ?? 0;
246
+ this.entries.clear();
247
+ this.entryBytes = 0;
148
248
  }
149
249
 
150
250
  /** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
@@ -153,6 +253,8 @@ export class WorkspaceIndex {
153
253
  this.lastTouched = relPath;
154
254
  }
155
255
 
256
+ getEntryBytes() { return this.entryBytes; }
257
+
156
258
  watch(root) {
157
259
  if (this.watchers.has(root)) return this.watchers.get(root);
158
260
  let ok = false;
@@ -166,12 +268,17 @@ export class WorkspaceIndex {
166
268
  timer = null;
167
269
  this.lists.clear();
168
270
  this.gitModified.delete(root);
271
+ this.entries.clear();
272
+ this.entryBytes = 0;
169
273
  }, WATCH_DEBOUNCE_MS);
274
+ timer.unref?.();
170
275
  });
171
276
 
172
277
  watcher.on("error", () => {
173
278
  this.watchers.set(root, false);
174
279
  this.lists.clear();
280
+ this.entries.clear();
281
+ this.entryBytes = 0;
175
282
  });
176
283
 
177
284
  if (isFunction(watcher.unref)) watcher.unref();
@@ -195,11 +302,7 @@ export class WorkspaceIndex {
195
302
  try {
196
303
  const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
197
304
 
198
- if (res.exitCode === 0) {
199
- for (const row of res.stdout.split("\0")) {
200
- if (row.length > 3) set.add(row.slice(3));
201
- }
202
- }
305
+ if (res.exitCode === 0) ingestPorcelain(res.stdout, set);
203
306
  } catch {}
204
307
 
205
308
  this.gitModified.set(root, set);
@@ -219,6 +322,21 @@ export class WorkspaceIndex {
219
322
  }
220
323
  }
221
324
 
325
+ async listFilesWithRg(root, includeHidden, signal) {
326
+ const args = ["rg", "--files"];
327
+
328
+ if (includeHidden) args.push("--hidden");
329
+ args.push("-g", "!.git/**", "-g", "!**/.git/**", "--", root);
330
+
331
+ try {
332
+ return parseRgFiles(await this.runCommand(args, { cwd: root, timeoutMs: 15_000, signal }), root);
333
+ } catch (err) {
334
+ signal?.throwIfAborted();
335
+
336
+ return { files: [], error: err.message, truncated: false, missing: !fs.existsSync(root) };
337
+ }
338
+ }
339
+
222
340
  /** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
223
341
  async files(root, includeHidden = false, signal) {
224
342
  const key = root + "\0" + (includeHidden ? "h" : "");
@@ -226,65 +344,112 @@ export class WorkspaceIndex {
226
344
  const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
227
345
 
228
346
  if (cached && Date.now() - cached.at < ttl) return cached.files;
229
- const args = ["rg", "--files"];
347
+ const listed = await this.listFilesWithRg(root, includeHidden, signal);
348
+ this.lists.set(key, { ...listed, at: Date.now() });
230
349
 
231
- if (includeHidden) args.push("--hidden");
232
- args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
233
- let files = [];
234
- let error;
235
- let truncated = false;
236
- let missing = false;
350
+ return listed.files;
351
+ }
237
352
 
238
- try {
239
- const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000, signal });
353
+ dropCached(filePath) {
354
+ const previous = this.entries.get(filePath);
240
355
 
241
- if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
242
- truncated = res.outputTruncated === true;
243
- const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
244
- files = output.split("\n").filter(Boolean).map(f => path.resolve(root, f)).sort();
245
- } catch (err) {
246
- signal?.throwIfAborted();
247
- error = err.message;
248
- missing = !fs.existsSync(root);
249
- }
356
+ if (previous) this.entryBytes -= previous.weight ?? 0;
357
+ this.entries.delete(filePath);
358
+ }
250
359
 
251
- this.lists.set(key, { files, at: Date.now(), error, truncated, missing });
360
+ rejectEntry(filePath) {
361
+ this.dropCached(filePath);
252
362
 
253
- return files;
363
+ return null;
254
364
  }
255
365
 
256
- /** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
257
- entry(filePath) {
258
- if (!isTextCandidate(filePath)) return null;
259
- let stat;
260
-
366
+ statOrNull(filePath) {
261
367
  try {
262
- stat = fs.statSync(filePath);
368
+ return fs.statSync(filePath);
263
369
  } catch {
264
- this.entries.delete(filePath);
265
-
266
370
  return null;
267
371
  }
372
+ }
268
373
 
269
- if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
374
+ cachedHit(filePath, stat) {
270
375
  const cached = this.entries.get(filePath);
271
376
 
272
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
273
- let text;
377
+ if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) return null;
378
+ this.entries.delete(filePath);
379
+ this.entries.set(filePath, cached);
380
+
381
+ return cached;
382
+ }
383
+
384
+ readFdText(filePath, fd) {
385
+ let actual = fs.fstatSync(fd);
386
+
387
+ if (!actual.isFile() || actual.size > MAX_FILE_BYTES) return this.rejectEntry(filePath);
388
+ // One reusable scratch read per index: allocating 512 KiB per file lets
389
+ // thousands of dead buffers pile up as RSS before a major GC notices.
390
+ this.scratchRead ??= Buffer.alloc(MAX_FILE_BYTES + 1);
391
+ const offset = readFdBuffer(fd, this.scratchRead);
392
+
393
+ if (offset > MAX_FILE_BYTES) return this.rejectEntry(filePath);
394
+ actual = fs.fstatSync(fd);
395
+
396
+ if (!actual.isFile() || actual.size !== offset) return this.rejectEntry(filePath);
397
+
398
+ return { text: this.scratchRead.subarray(0, offset).toString("utf8"), actual };
399
+ }
274
400
 
401
+ readIndexedText(filePath) {
275
402
  try {
276
- text = fs.readFileSync(filePath, "utf8");
403
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
404
+
405
+ try {
406
+ return this.readFdText(filePath, fd);
407
+ } finally { fs.closeSync(fd); }
277
408
  } catch {
278
- return null;
409
+ return this.rejectEntry(filePath);
410
+ }
411
+ }
412
+
413
+ evictOverflow() {
414
+ while (this.entryBytes > MAX_ENTRY_CACHE_BYTES && this.entries.size > 1) {
415
+ const oldest = this.entries.keys().next().value;
416
+ const evicted = this.entries.get(oldest);
417
+
418
+ this.entries.delete(oldest);
419
+ this.entryBytes -= evicted?.weight ?? 0;
279
420
  }
421
+ }
280
422
 
281
- if (text.includes("\0")) return null;
282
- const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
423
+ storeCreated(filePath, loaded) {
424
+ const created = { text: loaded.text, lower: loaded.text.toLowerCase(), mtimeMs: loaded.actual.mtimeMs, size: loaded.actual.size, weight: Math.max(1, loaded.actual.size) * 2, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
425
+ this.dropCached(filePath);
283
426
  this.entries.set(filePath, created);
427
+ this.entryBytes += created.weight;
428
+ this.evictOverflow();
284
429
 
285
430
  return created;
286
431
  }
287
432
 
433
+ /** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
434
+ entry(filePath) {
435
+ if (!isTextCandidate(filePath)) return null;
436
+ const stat = this.statOrNull(filePath);
437
+
438
+ if (!stat) return this.rejectEntry(filePath);
439
+
440
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return this.rejectEntry(filePath);
441
+ const cached = this.cachedHit(filePath, stat);
442
+
443
+ if (cached) return cached;
444
+ const loaded = this.readIndexedText(filePath);
445
+
446
+ if (!loaded) return null;
447
+
448
+ if (loaded.text.includes("\0")) return this.rejectEntry(filePath);
449
+
450
+ return this.storeCreated(filePath, loaded);
451
+ }
452
+
288
453
  static fromText(filePath, text) {
289
454
  return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
290
455
  }
@@ -300,7 +465,8 @@ export class WorkspaceIndex {
300
465
  for (let i = 0; i < raw.length; i++) {
301
466
  const trimmed = raw[i].trim();
302
467
  lower[i] = trimmed.toLowerCase();
303
- defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
468
+ const declared = DEF_PATTERN.exec(trimmed);
469
+ defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
304
470
  idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
305
471
  }
306
472
 
@@ -359,22 +525,27 @@ export class WorkspaceIndex {
359
525
  return hits;
360
526
  }
361
527
 
528
+ resolveGrepEntry(filePath, overlayText, root, regex, out) {
529
+ const pending = overlayText(filePath);
530
+
531
+ if (pending === undefined) return this.entry(filePath);
532
+
533
+ if (Buffer.byteLength(pending, "utf8") <= MAX_FILE_BYTES) return WorkspaceIndex.fromText(filePath, pending);
534
+ grepPendingHuge(pending, relativeSlash(root, filePath), regex, out);
535
+
536
+ return null;
537
+ }
538
+
362
539
  /** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
363
540
  grepRows(files, regex, root, overlayText = () => undefined) {
364
541
  const out = [];
365
542
  const nameRegex = new RegExp(regex.source, "i");
366
543
 
367
544
  for (const filePath of files) {
368
- const pending = overlayText(filePath);
369
- const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
370
-
371
- if (!e || !regex.test(e.text)) continue;
372
- const { raw, defNames } = WorkspaceIndex.linesOf(e);
373
- const rel = relativeSlash(root, filePath);
545
+ const e = this.resolveGrepEntry(filePath, overlayText, root, regex, out);
374
546
 
375
- for (let i = 0; i < raw.length; i++) {
376
- if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
377
- }
547
+ if (!e) continue;
548
+ grepEntryRows(e, filePath, root, regex, nameRegex, out);
378
549
  }
379
550
 
380
551
  return out;