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
@@ -20,6 +20,8 @@ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
20
20
 
21
21
  const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
22
22
 
23
+ const MAX_NEEDLE_CHARS = 128;
24
+
23
25
  const MAX_ALTERNATIVES = 3;
24
26
 
25
27
  /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
@@ -41,31 +43,40 @@ export function tokenizeQuery(query) {
41
43
  };
42
44
  }
43
45
 
44
- export function scorePathTopology(filePath, tokens, flags) {
45
- const normalized = filePath.replaceAll("\\", "/").toLowerCase();
46
+ function tokenPathScore(base, words, normalized, tokens) {
47
+ let score = 0;
48
+
49
+ for (const token of tokens) {
50
+ if (base === token || base.startsWith(token + ".")) score += 60;
51
+ else if (base.includes(token)) score += 30;
52
+ else if (words.includes(token)) score += 15;
53
+ else if (normalized.includes(token)) score += 5;
54
+ }
55
+
56
+ return score;
57
+ }
58
+
59
+ function topologyPenalty(normalized, flags) {
46
60
  const parts = normalized.split("/");
47
61
 
48
62
  if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
49
63
  const test = isTestPath(normalized);
50
64
 
51
65
  if (test && !flags.wantsTest) return -50;
52
-
53
66
  if (!test && flags.wantsTest) return -20;
54
- const base = path.basename(normalized);
55
- const words = normalized.split(/[^a-zA-Z0-9]+/);
67
+ }
68
+
69
+ export function scorePathTopology(filePath, tokens, flags) {
70
+ const normalized = filePath.replaceAll("\\", "/").toLowerCase();
71
+ const penalty = topologyPenalty(normalized, flags);
72
+
73
+ if (penalty !== undefined) return penalty;
56
74
  const ext = path.extname(normalized);
57
75
  let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
58
76
 
59
77
  if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
60
78
 
61
- for (const token of tokens) {
62
- if (base === token || base.startsWith(token + ".")) score += 60;
63
- else if (base.includes(token)) score += 30;
64
- else if (words.includes(token)) score += 15;
65
- else if (normalized.includes(token)) score += 5;
66
- }
67
-
68
- return score;
79
+ return score + tokenPathScore(path.basename(normalized), normalized.split(/[^a-zA-Z0-9]+/), normalized, tokens);
69
80
  }
70
81
 
71
82
  function inScope(filePath, dir, includeHidden) {
@@ -82,56 +93,66 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
82
93
  const lower = relative.toLowerCase();
83
94
  const base = path.basename(lower);
84
95
 
96
+ const extension = path.extname(base);
97
+ const stemBase = extension ? base.slice(0, -extension.length) : base;
85
98
  const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
86
- || base.slice(0, -path.extname(base).length) === query.toLowerCase();
99
+ || stemBase === query.toLowerCase();
100
+
101
+ const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
87
102
 
88
103
  return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
89
- pathCoverage: tokens.filter(token => lower.includes(token)).length,
104
+ pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
90
105
  matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
91
106
  line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
92
107
  }
93
108
 
94
- function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
95
- const text = raw.replace(/\r?\n$/, "");
96
- const lower = text.toLowerCase();
97
-
98
- if (isMatch) {
99
- const matches = tokens.filter(token => lower.includes(token));
109
+ function bestDeclaration(items, query, tokens, needles) {
110
+ let declaration;
111
+ let definitionCoverage = 0;
112
+ let exact = false;
113
+ const queryLower = query.toLowerCase();
100
114
 
101
- for (const token of matches) candidate.matched.add(token);
102
- const ext = path.extname(candidate.path).toLowerCase();
103
- const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
104
- let declaration;
105
- let definitionCoverage = 0;
106
- let exact = false;
115
+ for (const item of items) {
116
+ const name = item.name.toLowerCase();
117
+ const itemExact = name === queryLower;
118
+ const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
107
119
 
108
- for (const item of items) {
109
- const name = item.name.toLowerCase();
110
- const itemExact = name === query.toLowerCase();
111
- const coverage = tokens.filter(token => name.includes(token)).length;
120
+ if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
121
+ if (exact) break;
122
+ }
112
123
 
113
- if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
124
+ return { declaration, definitionCoverage, exact };
125
+ }
114
126
 
115
- if (exact) break;
116
- }
127
+ function applyMatch(candidate, lineNumber, text, query, tokens, needles, lower) {
128
+ const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
117
129
 
118
- const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
130
+ for (const token of matches) candidate.matched.add(token);
131
+ const ext = path.extname(candidate.path).toLowerCase();
132
+ const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
133
+ const { declaration, definitionCoverage, exact } = bestDeclaration(items, query, tokens, needles);
134
+ const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
119
135
 
120
- if (exact) candidate.exactLines.add(lineNumber);
136
+ if (exact) candidate.exactLines.add(lineNumber);
121
137
 
122
- if (score > candidate.anchorScore) {
123
- candidate.anchorScore = score;
124
- candidate.line = lineNumber;
125
- candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
126
- candidate.exactDefinition = exact;
127
- candidate.definitionCoverage = definitionCoverage;
128
- candidate.lineCoverage = matches.length;
129
- candidate.context.clear();
138
+ if (score > candidate.anchorScore) {
139
+ candidate.anchorScore = score;
140
+ candidate.line = lineNumber;
141
+ candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
142
+ candidate.exactDefinition = exact;
143
+ candidate.definitionCoverage = definitionCoverage;
144
+ candidate.lineCoverage = matches.length;
145
+ candidate.context.clear();
130
146
 
131
- for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
132
- }
147
+ for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
133
148
  }
149
+ }
150
+
151
+ function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
152
+ const text = raw.replace(/\r?\n$/, "");
153
+ const lower = text.toLowerCase();
134
154
 
155
+ if (isMatch) applyMatch(candidate, lineNumber, text, query, tokens, needles, lower);
135
156
  const excerpt = truncateChars(text, 240, "source line").text;
136
157
 
137
158
  if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
@@ -140,74 +161,116 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
140
161
  if (candidate.recent.length > 2) candidate.recent.shift();
141
162
  }
142
163
 
143
- function inspectOverlay(candidate, text, needles, query, tokens) {
144
- const lines = text.split("\n");
145
- const matches = [];
164
+ function inspectOverlay(candidate, text, needles, query, tokens, signal) {
165
+ let start = 0, line = 1, truncated = false;
166
+
167
+ // Keep only the candidate and its short context, not another copy of every
168
+ // line in a staged document. Oversized individual lines disclose uncertainty.
169
+ while (start < text.length) {
170
+ if ((line & 127) === 0) signal?.throwIfAborted();
171
+ const newline = text.indexOf("\n", start);
172
+ const end = newline < 0 ? text.length : newline + 1;
173
+
174
+ if (end - start > MAX_SEARCH_CHARS) truncated = true;
175
+ else {
176
+ const row = text.slice(start, end);
177
+ const lower = row.toLowerCase();
178
+ inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
179
+ }
180
+ start = end;
181
+ line++;
182
+ }
146
183
 
147
- for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
184
+ return truncated;
185
+ }
148
186
 
149
- for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
150
- candidate.context.clear();
187
+ function parseRgRecord(line, truncated, isLast) {
188
+ if (!line) return null;
151
189
 
152
- for (let i = Math.max(0, candidate.line - 3); i < Math.min(lines.length, candidate.line + 4); i++) candidate.context.set(i + 1, truncateChars(lines[i], 240, "source line").text);
190
+ try { return JSON.parse(line); } catch (error) {
191
+ if (truncated && isLast) return undefined;
192
+ throw error;
193
+ }
153
194
  }
154
195
 
155
- async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
156
- const needles = exact ? [query.toLowerCase()] : tokens;
157
- const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
196
+ function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles) {
197
+ if (record.type !== "match" && record.type !== "context") return;
198
+ const data = record.data;
158
199
 
159
- if (includeHidden) args.push("--hidden");
160
- args.push("-g", "!.git/**", "-g", "!**/.git/**");
200
+ if (!data?.path?.text || !isString(data.lines?.text)) return;
201
+ const filePath = path.resolve(dir, data.path.text);
161
202
 
162
- for (const needle of needles) args.push("-e", needle);
163
- args.push("--", dir);
203
+ if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) return;
204
+ let candidate = candidates.get(filePath);
164
205
 
165
- const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
166
- : { stdout: "", stderr: "", exitCode: 1 };
206
+ if (!candidate) {
207
+ candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
208
+ candidates.set(filePath, candidate);
209
+ }
167
210
 
168
- if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
169
- const candidates = new Map();
170
- const records = response.stdout.split("\n");
211
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
212
+ }
171
213
 
172
- for (let i = 0; i < records.length; i++) {
173
- if ((i & 127) === 0) signal?.throwIfAborted();
214
+ function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
215
+ let overlayTruncated = false;
174
216
 
175
- if (!records[i]) continue;
176
- let record;
217
+ for (const filePath of pendingPaths) {
218
+ const pending = overlayText(filePath);
177
219
 
178
- try { record = JSON.parse(records[i]); } catch (error) {
179
- if (response.outputTruncated && i === records.length - 1) break;
180
- throw error;
181
- }
220
+ if (pending === undefined) continue;
221
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
222
+ overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
182
223
 
183
- if (record.type !== "match" && record.type !== "context") continue;
184
- const data = record.data;
224
+ if (candidate.matched.size) candidates.set(filePath, candidate);
225
+ }
185
226
 
186
- if (!data?.path?.text || !isString(data.lines?.text)) continue;
187
- const filePath = path.resolve(dir, data.path.text);
227
+ return overlayTruncated;
228
+ }
188
229
 
189
- if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
190
- let candidate = candidates.get(filePath);
230
+ function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
231
+ const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
191
232
 
192
- if (!candidate) {
193
- candidate = makeCandidate(filePath, dir, query, tokens, flags);
194
- candidates.set(filePath, candidate);
195
- }
233
+ if (includeHidden) args.push("--hidden");
234
+ args.push("-g", "!.git/**", "-g", "!**/.git/**");
235
+ for (const needle of searchNeedles) args.push("-e", needle);
236
+ args.push("--", focusFile ?? dir);
196
237
 
197
- inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
198
- }
238
+ return args;
239
+ }
199
240
 
200
- for (const filePath of pendingPaths) {
201
- const pending = overlayText(filePath);
241
+ async function runContentSearch({ dir, includeHidden, searchNeedles, run, overlayText, signal, diskFiles, focusFile }) {
242
+ const args = rgSearchArgs(includeHidden, searchNeedles, focusFile, dir);
243
+ const response = diskFiles || (focusFile && overlayText(focusFile) === undefined)
244
+ ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
245
+ : { stdout: "", stderr: "", exitCode: 1 };
202
246
 
203
- if (pending === undefined) continue;
204
- const candidate = makeCandidate(filePath, dir, query, tokens, flags);
205
- inspectOverlay(candidate, pending, needles, query, tokens);
247
+ if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
206
248
 
207
- if (candidate.matched.size) candidates.set(filePath, candidate);
249
+ return response;
250
+ }
251
+
252
+ function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
253
+ const records = response.stdout.split("\n");
254
+
255
+ for (let i = 0; i < records.length; i++) {
256
+ if ((i & 127) === 0) signal?.throwIfAborted();
257
+ const record = parseRgRecord(records[i], response.outputTruncated, i === records.length - 1);
258
+
259
+ if (record === undefined) break;
260
+ if (!record) continue;
261
+ absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles);
208
262
  }
263
+ }
264
+
265
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
266
+ const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
267
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
268
+ const candidates = new Map();
269
+ const response = await runContentSearch({ dir, includeHidden, searchNeedles: [...new Set(needles)], run, overlayText, signal, diskFiles, focusFile });
270
+ absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal);
271
+ const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal);
209
272
 
210
- return { candidates, truncated: response.outputTruncated === true };
273
+ return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
211
274
  }
212
275
 
213
276
  function rankScore(candidate, tokenCount) {
@@ -224,126 +287,219 @@ function location(candidate, root) {
224
287
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
225
288
  }
226
289
 
227
- async function spanCandidates(filePath, lines, root, overlayText) {
290
+ async function spanCandidates(filePath, lines, root, overlayText, signal) {
228
291
  const staged = overlayText(filePath);
229
- const text = staged !== undefined ? staged : await fs.readFile(filePath, "utf8");
230
- const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
231
292
  const rel = path.relative(root, filePath);
293
+ let text = staged;
294
+
295
+ if (text === undefined) {
296
+ const file = await fs.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
297
+
298
+ try {
299
+ const stat = await file.stat();
300
+
301
+ if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
302
+ if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
303
+ text = await file.readFile({ encoding: "utf8", signal });
304
+ } finally { await file.close(); }
305
+ }
306
+ const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
232
307
 
233
308
  return lines.map(line => {
234
309
  const span = pickSpan(spans, { line }) ?? { start: line, end: line };
310
+ const end = Math.min(span.end, span.start + 119);
235
311
 
236
- return spanCandidate(rel, line, spanWindow(text, span.start, span.end));
312
+ return spanCandidate(rel, line, spanWindow(text, span.start, end));
237
313
  });
238
314
  }
239
315
 
240
- async function rankedSpanCandidates(ranked, root, overlayText) {
316
+ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
241
317
  const out = [];
242
318
 
243
319
  for (const candidate of ranked) {
244
320
  const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
245
- out.push(...await spanCandidates(candidate.path, lines, root, overlayText));
321
+ const staged = overlayText(candidate.path);
322
+ let large = false;
323
+
324
+ if (staged !== undefined) large = Buffer.byteLength(staged) > 512 * 1024;
325
+ else try { large = (await fs.stat(candidate.path)).size > 512 * 1024; } catch {}
326
+
327
+ if (large) out.push(location(candidate, root));
328
+ else {
329
+ try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
330
+ catch { signal?.throwIfAborted(); out.push(location(candidate, root)); }
331
+ }
246
332
  if (out.length >= MAX_ALTERNATIVES) break;
247
333
  }
248
334
 
249
335
  return out.slice(0, MAX_ALTERNATIVES);
250
336
  }
251
337
 
252
- export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
338
+ function admitSnapQuery(query, searchDir, root, includeHidden, pendingPaths) {
253
339
  const flags = tokenizeQuery(query);
340
+
341
+ if (flags.tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
254
342
  const tokens = [...new Set(flags.tokens.map(stem))];
255
343
 
256
344
  if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
257
-
258
- if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
259
345
  query = query.trim();
260
346
  const dir = path.resolve(searchDir || process.cwd());
261
347
 
262
348
  if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
263
- signal?.throwIfAborted();
264
349
  flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
265
- pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
266
350
 
267
- const diskFiles = await fs.stat(dir).then(stat => stat.isDirectory(), error => {
268
- if (error.code !== "ENOENT" || !pendingPaths.length) throw error;
269
-
270
- return false;
271
- });
351
+ return {
352
+ flags,
353
+ tokens,
354
+ query,
355
+ dir,
356
+ exact: /^[a-zA-Z_$][\w$]*$/.test(query),
357
+ pendingPaths: pendingPaths.filter(file => inScope(file, dir, includeHidden)),
358
+ };
359
+ }
272
360
 
273
- const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
274
- const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
275
- const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles });
276
- // A declaration hit needs no prerequisite file listing or persistent index.
277
- // Bare names can name files, even when callers mention the same word.
278
- const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
361
+ function listedSnapPaths(listing, dir, includeHidden, focusFile, pendingPaths) {
362
+ return [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...(focusFile ? [focusFile] : []), ...pendingPaths])]
363
+ .filter(file => inScope(file, dir, includeHidden));
364
+ }
279
365
 
280
- const listing = needsPaths && !search.truncated && diskFiles
281
- ? await run(["rg", "--files", "--null", ...(includeHidden ? ["--hidden"] : []), "-g", "!.git/**", "-g", "!**/.git/**", dir], { cwd: dir, signal, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS })
282
- : { stdout: "", exitCode: 1 };
366
+ function filenameEligible(search, filePath, relative, tokens, exact, queryLower) {
367
+ if (search.candidates.has(filePath)) return false;
368
+ if (!tokens.some(token => relative.includes(token))) return false;
369
+ if (exact && tokens.length > 1 && !relative.includes(queryLower)) return false;
283
370
 
284
- if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
371
+ return true;
372
+ }
285
373
 
286
- const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...pendingPaths])]
287
- .filter(file => inScope(file, dir, includeHidden));
374
+ function addFilenameCandidates(search, paths, { dir, focusFile, query, tokens, flags, exact }) {
375
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
376
+ const queryLower = query.toLowerCase();
288
377
 
289
378
  for (const filePath of paths) {
290
- if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
291
- const relative = path.relative(dir, filePath).toLowerCase();
379
+ const relative = path.relative(candidateRoot, filePath).toLowerCase();
292
380
 
293
- if (!tokens.some(token => relative.includes(token))) continue;
381
+ if (!filenameEligible(search, filePath, relative, tokens, exact, queryLower)) continue;
382
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
294
383
 
295
- if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
296
- const candidate = makeCandidate(filePath, dir, query, tokens, flags);
297
-
298
- if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
384
+ if (focusFile || candidate.pathScore > 0) search.candidates.set(filePath, candidate);
299
385
  }
386
+ }
300
387
 
388
+ function rankSnapCandidates(search, tokenCount, focusFile) {
301
389
  const ranked = [];
302
390
 
303
391
  for (const candidate of search.candidates.values()) {
304
- if (candidate.pathScore > -50) {
305
- ranked.push({ ...candidate, score: rankScore(candidate, tokens.length) });
392
+ if (focusFile || candidate.pathScore > -50) {
393
+ const score = rankScore(candidate, tokenCount);
394
+ ranked.push({ ...candidate, score: focusFile ? Math.max(1, score) : score });
306
395
  }
307
396
  }
308
397
 
309
398
  ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
310
399
 
311
- const incomplete = search.truncated || listing.outputTruncated === true;
312
- const relativeRoot = root ?? dir;
313
- const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
314
-
315
- if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
316
-
317
- if (!ranked.length) {
318
- // Reuse bounded filename discovery; fuzzy rank never authorizes a source selection.
319
- const eligible = exact && query.length >= 4 && query.length <= 64;
320
- const limited = eligible && paths.length > 1024;
400
+ return ranked;
401
+ }
321
402
 
322
- const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
323
- { ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
403
+ function emptySnap() {
404
+ return { path: null, line: null, signature: "", confidence: 0, context: [] };
405
+ }
324
406
 
325
- if (fuzzy.length || limited) return { ...empty, status: limited ? "incomplete" : "ambiguous",
326
- candidates: fuzzy.map(hit => ({ path: hit.path, line: 1, context: [], match: "fuzzy" })),
327
- message: limited ? "No literal match; fuzzy hints cover only 1024 paths. Narrow the directory." : "No literal match. Fuzzy filename hints are not selected source; read an explicit path." };
407
+ function uniqueExactHit(best, second) {
408
+ return best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
409
+ }
328
410
 
329
- return { ...empty, status: "not_found" };
330
- }
411
+ function snapCoverage(best, tokens) {
412
+ return Math.max(best.matched.size, best.pathCoverage) / tokens.length;
413
+ }
331
414
 
415
+ async function decideSnapResult(ranked, tokens, empty, candidates, relativeRoot, overlayText, signal) {
332
416
  const best = ranked[0];
333
417
  const second = ranked[1];
334
418
  const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
335
- const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
336
- const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
419
+ const coverage = snapCoverage(best, tokens);
420
+ const uniqueExact = uniqueExactHit(best, second);
337
421
 
338
422
  if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
339
- return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText) };
423
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText, signal) };
340
424
  }
341
425
 
342
426
  if (best.exactLines.size > 1) {
343
- return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText) };
427
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText, signal) };
344
428
  }
345
429
 
346
430
  const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
347
431
 
348
432
  return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
349
433
  }
434
+
435
+ function fuzzySnapMiss(exact, query, paths, pathContext, relativeRoot, empty) {
436
+ const eligible = exact && query.length >= 4 && query.length <= 64;
437
+ const limited = eligible && paths.length > 1024;
438
+ const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
439
+ { ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
440
+
441
+ if (fuzzy.length || limited) return { ...empty, status: limited ? "incomplete" : "ambiguous",
442
+ candidates: fuzzy.map(hit => ({ path: hit.path, line: 1, context: [], match: "fuzzy" })),
443
+ message: limited ? "No literal match; fuzzy hints cover only 1024 paths. Narrow the directory." : "No literal match. Fuzzy filename hints are not selected source; read an explicit path." };
444
+
445
+ return { ...empty, status: "not_found" };
446
+ }
447
+
448
+ async function snapListing(needsPaths, truncated, diskFiles, includeHidden, dir, run, signal) {
449
+ const listing = needsPaths && !truncated && diskFiles
450
+ ? await run(["rg", "--files", "--null", ...(includeHidden ? ["--hidden"] : []), "-g", "!.git/**", "-g", "!**/.git/**", dir], { cwd: dir, signal, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS })
451
+ : { stdout: "", exitCode: 1 };
452
+
453
+ if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
454
+
455
+ return listing;
456
+ }
457
+
458
+ function snapFocus(dirStat, pendingPaths, dir) {
459
+ return {
460
+ diskFiles: dirStat?.isDirectory() === true,
461
+ focusFile: dirStat?.isFile() === true || pendingPaths.includes(dir) ? dir : null,
462
+ };
463
+ }
464
+
465
+ export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
466
+ const admitted = admitSnapQuery(query, searchDir, root, includeHidden, pendingPaths);
467
+ const { flags, tokens, dir, exact } = admitted;
468
+ query = admitted.query;
469
+ pendingPaths = admitted.pendingPaths;
470
+ signal?.throwIfAborted();
471
+
472
+ const empty = emptySnap();
473
+ const dirStat = await fs.stat(dir).catch(error => {
474
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
475
+
476
+ return null;
477
+ });
478
+
479
+ if (!dirStat && !pendingPaths.length) return { ...empty, status: "not_found" };
480
+ const { diskFiles, focusFile } = snapFocus(dirStat, pendingPaths, dir);
481
+
482
+ if (!tokens.length) return { ...empty, status: "not_found" };
483
+
484
+ return rankSnapSearch({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile, root, pathContext, empty });
485
+ }
486
+
487
+ async function rankSnapSearch({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile, root, pathContext, empty }) {
488
+ const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile });
489
+ // A declaration hit needs no prerequisite file listing or persistent index.
490
+ // Bare names can name files, even when callers mention the same word.
491
+ const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
492
+ const listing = await snapListing(needsPaths, search.truncated, diskFiles, includeHidden, dir, run, signal);
493
+ const paths = listedSnapPaths(listing, dir, includeHidden, focusFile, pendingPaths);
494
+ addFilenameCandidates(search, paths, { dir, focusFile, query, tokens, flags, exact });
495
+ const ranked = rankSnapCandidates(search, tokens.length, focusFile);
496
+ const relativeRoot = root ?? dir;
497
+ const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
498
+
499
+ if (search.truncated || listing.outputTruncated === true) {
500
+ return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
501
+ }
502
+
503
+ return ranked.length ? decideSnapResult(ranked, tokens, empty, candidates, relativeRoot, overlayText, signal)
504
+ : fuzzySnapMiss(exact, query, paths, pathContext, relativeRoot, empty);
505
+ }
@@ -1,7 +1,8 @@
1
1
  import { truncateChars } from "../output/format.js";
2
+ import { isString } from "../shared/decode.js";
2
3
 
3
4
  export function pickSpan(spans, { line, name } = {}) {
4
- const needle = typeof name === "string" && /^[A-Za-z_$][\w$]*$/.test(name.trim()) ? name.trim().toLowerCase() : "";
5
+ const needle = isString(name) && /^[A-Za-z_$][\w$]*$/.test(name.trim()) ? name.trim().toLowerCase() : "";
5
6
  const named = needle ? spans.filter(item => item.name.toLowerCase() === needle) : [];
6
7
 
7
8
  if (named.length === 1) return named[0];