pi-supernova 0.4.0 → 0.6.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.
@@ -54,7 +54,11 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
54
54
  };
55
55
 
56
56
  function settingsFor(ctx) {
57
- return host.SettingsManager?.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted?.() === true });
57
+ try {
58
+ return host.SettingsManager?.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted?.() === true });
59
+ } catch {
60
+ return undefined;
61
+ }
58
62
  }
59
63
 
60
64
  async function readOne(args, signal, ctx, bridge, id) {
@@ -67,7 +71,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
67
71
  signal?.throwIfAborted();
68
72
  const image = /\.(png|jpe?g|gif|webp|bmp)$/i.test(target);
69
73
 
70
- if (stat?.isFile() && (args.about === undefined || image)) {
74
+ if (stat?.isFile() && args.about === undefined && args.query === undefined && args.resolve === undefined && args.outline === undefined && args.evidence === undefined && args.json === undefined && args.complete === undefined) {
71
75
  // Pi retains image handling, full text, line windows, truncation metadata,
72
76
  // and actionable continuation offsets. Do not summarize or dedupe these.
73
77
  return factories.read(ctx.cwd, { autoResizeImages: image ? settingsFor(ctx)?.getImageAutoResize() : undefined }).execute(id, { ...args, path: target }, signal, undefined, ctx);
@@ -77,7 +81,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
77
81
  }
78
82
 
79
83
  async function execute(name, id, args, signal, onUpdate, context) {
80
- const ctx = { ...context, cwd: context?.cwd || cwd };
84
+ const ctx = { ...context, cwd: isString(context?.cwd) && context.cwd ? context.cwd : cwd };
81
85
  const bridge = base.fork({ getCwd: () => ctx.cwd });
82
86
  bridge.bindCallContext(ctx, signal);
83
87
  signal?.throwIfAborted();
@@ -166,11 +170,17 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
166
170
  };
167
171
 
168
172
  if (name === "read") {
169
- tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question. An array of paths returns all readable files and labels individual errors.";
173
+ tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question (at most 16 keywords). An array of up to 64 paths returns all readable files and labels individual errors. Images return as attachments up to 20 MiB each; at most 16 image attachments are returned.";
170
174
  tool.parameters = { ...definition.parameters, properties: {
171
175
  ...definition.parameters.properties,
172
176
  path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }], description: "File, directory, source question, or up to 64 paths to read" },
173
- about: { type: "string", description: "Focus on this question or symbol; source selection reports uncertainty instead of guessing" },
177
+ about: { type: "string", description: "Focus on this question or symbol (at most 16 keywords); source selection reports uncertainty instead of guessing" },
178
+ query: { type: "string", description: "Source question; optional path scopes the search" },
179
+ outline: { type: "boolean" },
180
+ evidence: { type: "boolean" },
181
+ resolve: { type: "boolean" },
182
+ complete: { type: "boolean" },
183
+ json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }] },
174
184
  } };
175
185
  tool.promptGuidelines = [...(definition.promptGuidelines || []), "read can find source from a symbol or question; check its selection status before choosing a file. Plain file reads preserve full text within the stated limits."];
176
186
  }
@@ -179,7 +189,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
179
189
  }
180
190
 
181
191
  pi.on("session_start", (_event, ctx) => {
182
- cwd = ctx?.cwd || cwd;
192
+ cwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
183
193
  base.invalidateFiles();
184
194
  });
185
195
  pi.registerCommand("supernova", {
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import { WorkspaceIndex } from "./repo-index.js";
3
4
  import { tokenizeQuery, scorePathTopology, stem } from "./snap.js";
@@ -143,7 +144,7 @@ function lexicalSim(a, b) {
143
144
 
144
145
  function activateEntities(profile, graph) {
145
146
  const eta = new Map();
146
- const anchors = profile.subjects.length ? profile.subjects : profile.keywords;
147
+ const anchors = profile.subjects.length ? profile.subjects : profile.stems;
147
148
 
148
149
  for (const anchor of anchors) {
149
150
  let best = null;
@@ -388,12 +389,12 @@ function candidateFiles(files, profile, index, limit, overlayText) {
388
389
 
389
390
  scored.sort((a, b) => b.s - a.s);
390
391
  const chosen = new Set();
391
- const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
392
+ const anchors = (profile.subjects.length ? profile.subjects : profile.stems).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
392
393
 
393
394
  const pendingHits = files.filter(file => {
394
395
  const pending = overlayText(file);
395
396
 
396
- return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
397
+ return pending !== undefined && Buffer.byteLength(pending, "utf8") <= 512 * 1024 && anchors.some(anchor => pending.toLowerCase().includes(anchor));
397
398
  });
398
399
 
399
400
  const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
@@ -511,7 +512,12 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
511
512
  return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
512
513
  });
513
514
 
514
- const files = [...new Set([...await index.files(searchRoot), ...staged])];
515
+ const rootStat = await fs.stat(searchRoot).catch(error => {
516
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
517
+ throw error;
518
+ });
519
+ const diskFiles = rootStat?.isFile() ? [searchRoot] : rootStat ? await index.files(searchRoot) : [];
520
+ const files = [...new Set([...diskFiles, ...staged])];
515
521
 
516
522
  if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
517
523
 
@@ -520,7 +526,9 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
520
526
 
521
527
  for (const f of chosenFiles) {
522
528
  const pending = overlayText(f);
523
- const entry = pending === undefined ? index.entry(f) : WorkspaceIndex.fromText(f, pending);
529
+ const entry = pending === undefined
530
+ ? index.entry(f)
531
+ : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(f, pending) : null;
524
532
 
525
533
  if (!entry) continue;
526
534
  spans.push(...spansOf(entry, f, opts.maxSpanLines));
@@ -15,6 +15,7 @@ const AI_DECAY = Math.LN2 / 3; // per day
15
15
  const AI_MAX_HISTORY_DAYS = 7;
16
16
 
17
17
  const MAX_TIMESTAMPS_PER_FILE = 128;
18
+ const MAX_FRECENCY_FILES = 10000;
18
19
 
19
20
  const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
20
21
 
@@ -26,7 +27,10 @@ export class Frecency {
26
27
  record(filePath, at = Date.now() / 1000) {
27
28
  let list = this.access.get(filePath);
28
29
 
29
- if (!list) this.access.set(filePath, (list = []));
30
+ if (!list) {
31
+ if (this.access.size >= MAX_FRECENCY_FILES) this.access.delete(this.access.keys().next().value);
32
+ this.access.set(filePath, (list = []));
33
+ }
30
34
  list.push(at);
31
35
 
32
36
  if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
@@ -124,20 +128,35 @@ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false }
124
128
 
125
129
  if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
126
130
 
127
- if (maxTypos <= 0 || needle.length < 3) return null;
128
- let best = null;
131
+ if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
132
+ const memo = new Map();
129
133
 
130
- for (let i = 0; i < needle.length; i++) {
131
- const shorter = needle.slice(0, i) + needle.slice(i + 1);
132
- const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
134
+ const visit = (part, typosLeft) => {
135
+ const key = part + "\0" + typosLeft;
133
136
 
134
- if (!m) continue;
135
- const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
137
+ if (memo.has(key)) return memo.get(key);
138
+ let best = matchOnce(part, hay, caseSensitive);
136
139
 
137
- if (!best || scored.score > best.score) best = scored;
138
- }
140
+ if (best) best = { ...best, typos: 0, exact: hay.toLowerCase() === part.toLowerCase() };
141
+
142
+ if (typosLeft > 0) {
143
+ for (let i = 0; i < part.length; i++) {
144
+ const shorter = part.slice(0, i) + part.slice(i + 1);
145
+ const m = visit(shorter, typosLeft - 1);
146
+
147
+ if (!m) continue;
148
+ const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
149
+
150
+ if (!best || scored.score > best.score) best = scored;
151
+ }
152
+ }
153
+
154
+ memo.set(key, best);
139
155
 
140
- return best;
156
+ return best;
157
+ };
158
+
159
+ return visit(needle, maxTypos);
141
160
  }
142
161
 
143
162
  export function smartCase(query) {
@@ -164,7 +183,7 @@ function distancePenalty(currentDir, candidateDir) {
164
183
  export function rankPaths(query, paths, ctx = {}) {
165
184
  const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
166
185
 
167
- if (parts.length === 0) return [];
186
+ if (parts.length === 0 || parts.length > 16) return [];
168
187
  const caseSensitive = smartCase(query);
169
188
  const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
170
189
  const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
@@ -212,7 +231,9 @@ function filenameBonus(base, rel, filenameStart, first, needle) {
212
231
 
213
232
  /** fff: frecency boost base·f/100 and +15% for git-modified files. */
214
233
  function contextBoost(base, rel, ctx) {
215
- const frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0;
234
+ let frecency = 0;
235
+
236
+ try { frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0; } catch {}
216
237
  const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
217
238
 
218
239
  return Math.floor((base * frecency) / 100) + gitBoost;
@@ -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();
@@ -118,7 +119,15 @@ export function outlineFile(entry, relPath, about, options = {}) {
118
119
  }
119
120
 
120
121
  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";
122
+ const label = about ? String(about).replace(/\s+/g, " ").slice(0, 120) : "";
123
+ const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (label ? " for \"" + label + "\"" : "") + " · read(path, line, count) for a folded body";
124
+ let text = title + "\n" + parts.join("\n");
122
125
 
123
- return { text: title + "\n" + parts.join("\n"), expanded: expanded.size, declarations: spans.length };
126
+ if (text.length > opts.maxChars) {
127
+ const end = text.lastIndexOf("\n", Math.max(0, opts.maxChars - 160));
128
+
129
+ text = (end > title.length ? text.slice(0, end) : text.slice(0, opts.maxChars)) + "\n … outline truncated; use read(path, line, count) for later declarations";
130
+ }
131
+
132
+ return { text, expanded: expanded.size, declarations: spans.length };
124
133
  }
@@ -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,11 +101,48 @@ export function globToRegExp(glob) {
96
101
  return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
97
102
  }
98
103
 
104
+ function declarationEnd(raw, lower, start, lineCount, ext) {
105
+ if (ext === ".py") {
106
+ const indentOf = (i) => raw[i].length - raw[i].trimStart().length;
107
+ const base = indentOf(start - 1);
108
+ let end = start;
109
+
110
+ for (let i = start; i < lineCount; i++) {
111
+ if (lower[i] === "") { end = i + 1; continue; }
112
+ if (indentOf(i) <= base) break;
113
+ end = i + 1;
114
+ }
115
+
116
+ return Math.min(end, lineCount);
117
+ }
118
+
119
+ let depth = 0;
120
+
121
+ for (const ch of raw[start - 1] ?? "") {
122
+ if (ch === "{") depth++;
123
+ else if (ch === "}") depth--;
124
+ }
125
+
126
+ if (depth <= 0) return start;
127
+
128
+ for (let i = start; i < raw.length; i++) {
129
+ for (const ch of raw[i]) {
130
+ if (ch === "{") depth++;
131
+ else if (ch === "}") depth--;
132
+ }
133
+
134
+ if (depth <= 0) return i + 1;
135
+ }
136
+
137
+ return lineCount;
138
+ }
139
+
99
140
  export class WorkspaceIndex {
100
141
  constructor(runCommand) {
101
142
  this.runCommand = runCommand;
102
143
  this.lists = new Map();
103
144
  this.entries = new Map();
145
+ this.entryBytes = 0;
104
146
  this.watchers = new Map();
105
147
  this.frecency = new Frecency();
106
148
  this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
@@ -109,6 +151,11 @@ export class WorkspaceIndex {
109
151
 
110
152
  invalidate() {
111
153
  this.lists.clear();
154
+ this.gitModified.clear();
155
+
156
+ for (const entry of this.entries.values()) this.entryBytes -= entry.weight ?? 0;
157
+ this.entries.clear();
158
+ this.entryBytes = 0;
112
159
  }
113
160
 
114
161
  /** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
@@ -130,12 +177,17 @@ export class WorkspaceIndex {
130
177
  timer = null;
131
178
  this.lists.clear();
132
179
  this.gitModified.delete(root);
180
+ this.entries.clear();
181
+ this.entryBytes = 0;
133
182
  }, WATCH_DEBOUNCE_MS);
183
+ timer.unref?.();
134
184
  });
135
185
 
136
186
  watcher.on("error", () => {
137
187
  this.watchers.set(root, false);
138
188
  this.lists.clear();
189
+ this.entries.clear();
190
+ this.entryBytes = 0;
139
191
  });
140
192
 
141
193
  if (isFunction(watcher.unref)) watcher.unref();
@@ -160,8 +212,21 @@ export class WorkspaceIndex {
160
212
  const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
161
213
 
162
214
  if (res.exitCode === 0) {
163
- for (const row of res.stdout.split("\0")) {
164
- if (row.length > 3) set.add(row.slice(3));
215
+ const rows = res.stdout.split("\0");
216
+
217
+ for (let i = 0; i < rows.length; i++) {
218
+ const row = rows[i];
219
+
220
+ if (row.length <= 3) continue;
221
+ const status = row.slice(0, 2);
222
+ const file = row.slice(3);
223
+
224
+ if (file) set.add(file);
225
+ if ((status.includes("R") || status.includes("C")) && i + 1 < rows.length) {
226
+ const target = rows[++i];
227
+
228
+ if (target) set.add(target);
229
+ }
165
230
  }
166
231
  }
167
232
  } catch {}
@@ -193,7 +258,7 @@ export class WorkspaceIndex {
193
258
  const args = ["rg", "--files"];
194
259
 
195
260
  if (includeHidden) args.push("--hidden");
196
- args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
261
+ args.push("-g", "!.git/**", "-g", "!**/.git/**", "--", root);
197
262
  let files = [];
198
263
  let error;
199
264
  let truncated = false;
@@ -225,26 +290,107 @@ export class WorkspaceIndex {
225
290
  try {
226
291
  stat = fs.statSync(filePath);
227
292
  } catch {
293
+ const previous = this.entries.get(filePath);
294
+
295
+ if (previous) this.entryBytes -= previous.weight ?? 0;
228
296
  this.entries.delete(filePath);
229
297
 
230
298
  return null;
231
299
  }
232
300
 
233
- if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
301
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
302
+ const previous = this.entries.get(filePath);
303
+
304
+ if (previous) this.entryBytes -= previous.weight ?? 0;
305
+ this.entries.delete(filePath);
306
+
307
+ return null;
308
+ }
234
309
  const cached = this.entries.get(filePath);
235
310
 
236
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
311
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
312
+ this.entries.delete(filePath);
313
+ this.entries.set(filePath, cached);
314
+
315
+ return cached;
316
+ }
237
317
  let text;
318
+ let actual = stat;
238
319
 
239
320
  try {
240
- text = fs.readFileSync(filePath, "utf8");
321
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
322
+
323
+ try {
324
+ actual = fs.fstatSync(fd);
325
+ if (!actual.isFile() || actual.size > MAX_FILE_BYTES) {
326
+ const previous = this.entries.get(filePath);
327
+
328
+ if (previous) this.entryBytes -= previous.weight ?? 0;
329
+ this.entries.delete(filePath);
330
+
331
+ return null;
332
+ }
333
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
334
+ let offset = 0;
335
+
336
+ while (offset < buffer.length) {
337
+ const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
338
+
339
+ if (read <= 0) break;
340
+ offset += read;
341
+ }
342
+
343
+ if (offset > MAX_FILE_BYTES) {
344
+ const previous = this.entries.get(filePath);
345
+
346
+ if (previous) this.entryBytes -= previous.weight ?? 0;
347
+ this.entries.delete(filePath);
348
+
349
+ return null;
350
+ }
351
+ actual = fs.fstatSync(fd);
352
+ if (!actual.isFile() || actual.size !== offset) {
353
+ const previous = this.entries.get(filePath);
354
+
355
+ if (previous) this.entryBytes -= previous.weight ?? 0;
356
+ this.entries.delete(filePath);
357
+
358
+ return null;
359
+ }
360
+ text = buffer.subarray(0, offset).toString("utf8");
361
+ } finally { fs.closeSync(fd); }
241
362
  } catch {
363
+ const previous = this.entries.get(filePath);
364
+
365
+ if (previous) this.entryBytes -= previous.weight ?? 0;
366
+ this.entries.delete(filePath);
367
+
368
+ return null;
369
+ }
370
+
371
+ if (text.includes("\0")) {
372
+ const previous = this.entries.get(filePath);
373
+
374
+ if (previous) this.entryBytes -= previous.weight ?? 0;
375
+ this.entries.delete(filePath);
376
+
242
377
  return null;
243
378
  }
379
+ const created = { text, lower: text.toLowerCase(), mtimeMs: actual.mtimeMs, size: actual.size, weight: Math.max(1, actual.size) * 2, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
380
+ const previous = this.entries.get(filePath);
244
381
 
245
- if (text.includes("\0")) return null;
246
- const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
382
+ if (previous) this.entryBytes -= previous.weight ?? 0;
383
+ this.entries.delete(filePath);
247
384
  this.entries.set(filePath, created);
385
+ this.entryBytes += created.weight;
386
+
387
+ while (this.entryBytes > MAX_ENTRY_CACHE_BYTES && this.entries.size > 1) {
388
+ const oldest = this.entries.keys().next().value;
389
+ const evicted = this.entries.get(oldest);
390
+
391
+ this.entries.delete(oldest);
392
+ this.entryBytes -= evicted?.weight ?? 0;
393
+ }
248
394
 
249
395
  return created;
250
396
  }
@@ -264,7 +410,8 @@ export class WorkspaceIndex {
264
410
  for (let i = 0; i < raw.length; i++) {
265
411
  const trimmed = raw[i].trim();
266
412
  lower[i] = trimmed.toLowerCase();
267
- defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
413
+ const declared = DEF_PATTERN.exec(trimmed);
414
+ defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
268
415
  idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
269
416
  }
270
417
 
@@ -274,18 +421,18 @@ export class WorkspaceIndex {
274
421
  }
275
422
 
276
423
  /**
277
- * Declaration spans [start, end] (1-based, inclusive) in file order, trailing blank lines trimmed.
278
- * A span runs to the line before the next declaration; the file's leading header is not a span.
424
+ * Declaration spans [start, end] (1-based, inclusive). Nested bodies stay inside the parent
425
+ * (brace-matched for JS-like, indent for Python). The file's leading header is not a span.
279
426
  */
280
427
  static spansOf(entry) {
281
428
  if (entry.spans) return entry.spans;
282
429
  const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
283
- const { lower } = WorkspaceIndex.linesOf(entry);
430
+ const { lower, raw } = WorkspaceIndex.linesOf(entry);
284
431
  const spans = [];
285
432
 
286
433
  for (let i = 0; i < items.length; i++) {
287
434
  const start = items[i].line;
288
- let end = Math.min(i + 1 < items.length ? items[i + 1].line - 1 : lineCount, lineCount);
435
+ let end = declarationEnd(raw, lower, start, lineCount, entry.ext);
289
436
 
290
437
  while (end > start && lower[end - 1] === "") end--;
291
438
  spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
@@ -330,9 +477,33 @@ export class WorkspaceIndex {
330
477
 
331
478
  for (const filePath of files) {
332
479
  const pending = overlayText(filePath);
333
- const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
480
+ let e = null;
481
+
482
+ if (pending === undefined) e = this.entry(filePath);
483
+ else if (Buffer.byteLength(pending, "utf8") <= MAX_FILE_BYTES) e = WorkspaceIndex.fromText(filePath, pending);
484
+ else {
485
+ const rel = relativeSlash(root, filePath);
486
+ let start = 0;
487
+ let line = 0;
488
+
489
+ while (start <= pending.length) {
490
+ const end = pending.indexOf("\n", start);
491
+ const stop = end === -1 ? pending.length : end;
492
+ const text = pending.slice(start, stop).replace(/\r$/, "");
493
+
494
+ line++;
495
+ if (regex.test(text)) out.push({ rel, line, text, def: false });
496
+ if (end === -1) break;
497
+ start = end + 1;
498
+ }
499
+
500
+ continue;
501
+ }
502
+
503
+ if (!e) continue;
504
+ const lineAnchored = /\^|\$/.test(regex.source.replace(/\\[\^$]|\[[^\]]*\]/g, ""));
334
505
 
335
- if (!e || !regex.test(e.text)) continue;
506
+ if (!lineAnchored && !regex.test(e.text)) continue;
336
507
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
337
508
  const rel = relativeSlash(root, filePath);
338
509