pi-supernova 0.5.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
  }
@@ -137,6 +142,7 @@ export class WorkspaceIndex {
137
142
  this.runCommand = runCommand;
138
143
  this.lists = new Map();
139
144
  this.entries = new Map();
145
+ this.entryBytes = 0;
140
146
  this.watchers = new Map();
141
147
  this.frecency = new Frecency();
142
148
  this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
@@ -145,6 +151,11 @@ export class WorkspaceIndex {
145
151
 
146
152
  invalidate() {
147
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;
148
159
  }
149
160
 
150
161
  /** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
@@ -166,12 +177,17 @@ export class WorkspaceIndex {
166
177
  timer = null;
167
178
  this.lists.clear();
168
179
  this.gitModified.delete(root);
180
+ this.entries.clear();
181
+ this.entryBytes = 0;
169
182
  }, WATCH_DEBOUNCE_MS);
183
+ timer.unref?.();
170
184
  });
171
185
 
172
186
  watcher.on("error", () => {
173
187
  this.watchers.set(root, false);
174
188
  this.lists.clear();
189
+ this.entries.clear();
190
+ this.entryBytes = 0;
175
191
  });
176
192
 
177
193
  if (isFunction(watcher.unref)) watcher.unref();
@@ -196,8 +212,21 @@ export class WorkspaceIndex {
196
212
  const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
197
213
 
198
214
  if (res.exitCode === 0) {
199
- for (const row of res.stdout.split("\0")) {
200
- 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
+ }
201
230
  }
202
231
  }
203
232
  } catch {}
@@ -229,7 +258,7 @@ export class WorkspaceIndex {
229
258
  const args = ["rg", "--files"];
230
259
 
231
260
  if (includeHidden) args.push("--hidden");
232
- args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
261
+ args.push("-g", "!.git/**", "-g", "!**/.git/**", "--", root);
233
262
  let files = [];
234
263
  let error;
235
264
  let truncated = false;
@@ -261,26 +290,107 @@ export class WorkspaceIndex {
261
290
  try {
262
291
  stat = fs.statSync(filePath);
263
292
  } catch {
293
+ const previous = this.entries.get(filePath);
294
+
295
+ if (previous) this.entryBytes -= previous.weight ?? 0;
264
296
  this.entries.delete(filePath);
265
297
 
266
298
  return null;
267
299
  }
268
300
 
269
- 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
+ }
270
309
  const cached = this.entries.get(filePath);
271
310
 
272
- 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
+ }
273
317
  let text;
318
+ let actual = stat;
274
319
 
275
320
  try {
276
- 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); }
277
362
  } catch {
363
+ const previous = this.entries.get(filePath);
364
+
365
+ if (previous) this.entryBytes -= previous.weight ?? 0;
366
+ this.entries.delete(filePath);
367
+
278
368
  return null;
279
369
  }
280
370
 
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 };
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
+
377
+ return null;
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);
381
+
382
+ if (previous) this.entryBytes -= previous.weight ?? 0;
383
+ this.entries.delete(filePath);
283
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
+ }
284
394
 
285
395
  return created;
286
396
  }
@@ -300,7 +410,8 @@ export class WorkspaceIndex {
300
410
  for (let i = 0; i < raw.length; i++) {
301
411
  const trimmed = raw[i].trim();
302
412
  lower[i] = trimmed.toLowerCase();
303
- defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
413
+ const declared = DEF_PATTERN.exec(trimmed);
414
+ defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
304
415
  idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
305
416
  }
306
417
 
@@ -366,9 +477,33 @@ export class WorkspaceIndex {
366
477
 
367
478
  for (const filePath of files) {
368
479
  const pending = overlayText(filePath);
369
- 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, ""));
370
505
 
371
- if (!e || !regex.test(e.text)) continue;
506
+ if (!lineAnchored && !regex.test(e.text)) continue;
372
507
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
373
508
  const rel = relativeSlash(root, filePath);
374
509
 
@@ -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,22 @@ 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
+
15
32
  /** One bounded direct search for all changed names; no repository index or per-name spawn. */
16
33
  export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
17
34
  const references = new Map(names.map(name => [name, []]));
@@ -48,10 +65,10 @@ export async function referencesForNames({ root, names, excludePath, overlayText
48
65
  if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
49
66
  }
50
67
 
51
- for (const file of pendingPaths) {
68
+ for (const file of pendingInScope(root, pendingPaths)) {
52
69
  const text = overlayText(file);
53
70
 
54
- if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
71
+ if (text !== undefined && Buffer.byteLength(text, "utf8") <= 512 * 1024) text.split("\n").forEach((line, i) => add(file, i + 1, line));
55
72
  }
56
73
 
57
74
  return { references, incomplete: result.outputTruncated === true };
@@ -59,29 +76,57 @@ export async function referencesForNames({ root, names, excludePath, overlayText
59
76
 
60
77
  export function rgGrepArgs(pattern, params, searchPath) {
61
78
  const args = ["--line-number", "--no-heading", "--color", "never"];
79
+ const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
62
80
 
63
- if (params?.caseSensitive !== true) args.push("--ignore-case");
81
+ if (!caseSensitive) args.push("--ignore-case");
64
82
 
65
83
  if (params?.glob) args.push("--glob", String(params.glob));
84
+ if (Number.isInteger(params?.limit) && params.limit > 0) args.push("--max-count", String(Math.min(params.limit, 2000)));
66
85
  args.push("--", pattern, searchPath);
67
86
 
68
87
  return args;
69
88
  }
70
89
 
71
90
  /** 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) {
91
+ export async function listWithTools(searchDir, pattern, cwd, signal, pendingPaths = []) {
92
+ const stat = await fs.stat(searchDir).catch(() => null);
93
+ const pendingAbs = pendingInScope(searchDir, pendingPaths);
94
+ const pending = pendingAbs.map(file => relativeSlash(cwd, file));
95
+
96
+ let matcher = null;
97
+
98
+ if (pattern) {
99
+ try { matcher = globToRegExp(pattern); }
100
+ catch { matcher = /^$/; }
101
+ }
102
+
103
+ if (stat?.isFile() || (!stat?.isDirectory() && pending.length)) {
104
+ const rel = stat?.isFile() ? relativeSlash(cwd, searchDir) : null;
105
+ const rows = [...new Set([...(rel ? [rel] : []), ...pending])].filter(file => !matcher || matcher.test(file));
106
+
107
+ return textResult(rows.length ? rows.join("\n") + "\n" : "", { via: pending.length ? "vfs" : "file" });
108
+ }
109
+
110
+ const pendingMerged = pendingAbs.filter((_, i) => !matcher || matcher.test(pending[i]));
111
+ const mergePending = stdout => {
112
+ const diskRows = String(stdout || "").split("\n").filter(Boolean)
113
+ .map(row => relativeSlash(cwd, path.isAbsolute(row) ? row : path.resolve(cwd, row)));
114
+ const rows = [...new Set([...diskRows, ...pendingMerged])];
115
+
116
+ return rows.length ? rows.join("\n") + "\n" : "";
117
+ };
73
118
  const args = ["--files"];
74
119
 
75
120
  if (pattern) args.push("-g", pattern);
76
121
  const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
77
122
 
78
- if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
123
+ if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(mergePending(res.stdout), { via: "rg", outputTruncated: res.outputTruncated === true });
79
124
  const findArgs = [searchDir];
80
125
 
81
126
  if (pattern) findArgs.push("-name", pattern);
82
127
  const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
83
128
 
84
- return textResult(findRes.stdout, { via: "find" });
129
+ return textResult(mergePending(findRes.stdout), { via: "find", outputTruncated: findRes.outputTruncated === true });
85
130
  }
86
131
 
87
132
  const GLOB_CHARS = /[*?[\]{}]/;
@@ -90,9 +135,9 @@ const GLOB_CHARS = /[*?[\]{}]/;
90
135
  * fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
91
136
  * Returns "path" rows (best first) or null when the pattern is a real glob.
92
137
  */
93
- export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
138
+ export async function fuzzyFind(index, root, cwd, pattern, limit = 20, pendingPaths = []) {
94
139
  if (!pattern || GLOB_CHARS.test(pattern)) return null;
95
- const files = await index.files(root);
140
+ const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
96
141
 
97
142
  if (!index.canScan(files)) return null;
98
143
  const rel = files.map((f) => relativeSlash(cwd, f));
@@ -109,12 +154,12 @@ export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
109
154
  }
110
155
 
111
156
  /** 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) {
157
+ export async function grepIndexed(index, pattern, params, searchPath, cwd, overlayText = () => undefined, pendingPaths = []) {
113
158
  const compiled = grepRegex(pattern, params);
114
159
 
115
160
  if (!compiled) return null;
116
161
  const { regex, caseSensitive } = compiled;
117
- let files = await index.files(searchPath);
162
+ let files = [...new Set([...await candidateFileList(index, searchPath), ...pendingInScope(searchPath, pendingPaths)])];
118
163
 
119
164
  if (!index.canScan(files)) return null;
120
165
 
@@ -123,14 +168,19 @@ export async function grepIndexed(index, pattern, params, searchPath, cwd) {
123
168
  files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
124
169
  }
125
170
 
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;
171
+ for (const file of files) {
172
+ const overlay = overlayText(file);
173
+
174
+ if (overlay === undefined && index.entry(file) === null) return null;
175
+ }
176
+ const rows = index.grepRows(files, regex, cwd, overlayText);
177
+ const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText) : rows;
128
178
 
129
179
  return formatGrepRows(fallback, grepLimit(params));
130
180
  }
131
181
 
132
182
  function grepLimit(params) {
133
- return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
183
+ return Number.isInteger(params?.limit) && params.limit > 0 ? Math.min(params.limit, 2000) : 200;
134
184
  }
135
185
 
136
186
  function grepRegex(pattern, params) {
@@ -144,12 +194,15 @@ function grepRegex(pattern, params) {
144
194
  }
145
195
 
146
196
  /** 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) {
197
+ function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText = () => undefined) {
148
198
  const maxTypos = pattern.length >= 8 ? 2 : 1;
149
199
  const rows = [];
150
200
 
151
201
  for (const filePath of files) {
152
- const e = index.entry(filePath);
202
+ const pending = overlayText(filePath);
203
+ const e = pending === undefined
204
+ ? index.entry(filePath)
205
+ : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
153
206
 
154
207
  if (!e) continue;
155
208
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
@@ -197,8 +250,8 @@ function formatGrepRows(rows, limit) {
197
250
  }
198
251
 
199
252
  /** 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);
253
+ export async function listIndexed(index, root, cwd, pattern, pendingPaths = []) {
254
+ const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
202
255
 
203
256
  if (!index.canScan(files)) return null;
204
257
  const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));