pi-supernova 0.6.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 (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +104 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  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 +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  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 +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +289 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. package/src/ui/render.js +241 -145
@@ -43,31 +43,40 @@ export function tokenizeQuery(query) {
43
43
  };
44
44
  }
45
45
 
46
- export function scorePathTopology(filePath, tokens, flags) {
47
- 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) {
48
60
  const parts = normalized.split("/");
49
61
 
50
62
  if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
51
63
  const test = isTestPath(normalized);
52
64
 
53
65
  if (test && !flags.wantsTest) return -50;
54
-
55
66
  if (!test && flags.wantsTest) return -20;
56
- const base = path.basename(normalized);
57
- 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;
58
74
  const ext = path.extname(normalized);
59
75
  let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
60
76
 
61
77
  if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
62
78
 
63
- for (const token of tokens) {
64
- if (base === token || base.startsWith(token + ".")) score += 60;
65
- else if (base.includes(token)) score += 30;
66
- else if (words.includes(token)) score += 15;
67
- else if (normalized.includes(token)) score += 5;
68
- }
69
-
70
- return score;
79
+ return score + tokenPathScore(path.basename(normalized), normalized.split(/[^a-zA-Z0-9]+/), normalized, tokens);
71
80
  }
72
81
 
73
82
  function inScope(filePath, dir, includeHidden) {
@@ -97,47 +106,53 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
97
106
  line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
98
107
  }
99
108
 
100
- function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
101
- const text = raw.replace(/\r?\n$/, "");
102
- const lower = text.toLowerCase();
103
-
104
- if (isMatch) {
105
- const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
109
+ function bestDeclaration(items, query, tokens, needles) {
110
+ let declaration;
111
+ let definitionCoverage = 0;
112
+ let exact = false;
113
+ const queryLower = query.toLowerCase();
106
114
 
107
- for (const token of matches) candidate.matched.add(token);
108
- const ext = path.extname(candidate.path).toLowerCase();
109
- const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
110
- let declaration;
111
- let definitionCoverage = 0;
112
- 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;
113
119
 
114
- for (const item of items) {
115
- const name = item.name.toLowerCase();
116
- const itemExact = name === query.toLowerCase();
117
- const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
120
+ if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
121
+ if (exact) break;
122
+ }
118
123
 
119
- if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
124
+ return { declaration, definitionCoverage, exact };
125
+ }
120
126
 
121
- if (exact) break;
122
- }
127
+ function applyMatch(candidate, lineNumber, text, query, tokens, needles, lower) {
128
+ const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
123
129
 
124
- 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;
125
135
 
126
- if (exact) candidate.exactLines.add(lineNumber);
136
+ if (exact) candidate.exactLines.add(lineNumber);
127
137
 
128
- if (score > candidate.anchorScore) {
129
- candidate.anchorScore = score;
130
- candidate.line = lineNumber;
131
- candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
132
- candidate.exactDefinition = exact;
133
- candidate.definitionCoverage = definitionCoverage;
134
- candidate.lineCoverage = matches.length;
135
- 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();
136
146
 
137
- for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
138
- }
147
+ for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
139
148
  }
149
+ }
140
150
 
151
+ function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
152
+ const text = raw.replace(/\r?\n$/, "");
153
+ const lower = text.toLowerCase();
154
+
155
+ if (isMatch) applyMatch(candidate, lineNumber, text, query, tokens, needles, lower);
141
156
  const excerpt = truncateChars(text, 240, "source line").text;
142
157
 
143
158
  if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
@@ -169,54 +184,34 @@ function inspectOverlay(candidate, text, needles, query, tokens, signal) {
169
184
  return truncated;
170
185
  }
171
186
 
172
- async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
173
- const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
174
- const searchNeedles = [...new Set(needles)];
175
- const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
176
- const candidates = new Map();
177
-
178
- const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
179
-
180
- if (includeHidden) args.push("--hidden");
181
- args.push("-g", "!.git/**", "-g", "!**/.git/**");
182
-
183
- for (const needle of searchNeedles) args.push("-e", needle);
184
- args.push("--", focusFile ?? dir);
185
-
186
- const response = diskFiles || (focusFile && overlayText(focusFile) === undefined) ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
187
- : { stdout: "", stderr: "", exitCode: 1 };
188
-
189
- if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
190
- const records = response.stdout.split("\n");
191
-
192
- for (let i = 0; i < records.length; i++) {
193
- if ((i & 127) === 0) signal?.throwIfAborted();
194
-
195
- if (!records[i]) continue;
196
- let record;
187
+ function parseRgRecord(line, truncated, isLast) {
188
+ if (!line) return null;
197
189
 
198
- try { record = JSON.parse(records[i]); } catch (error) {
199
- if (response.outputTruncated && i === records.length - 1) break;
200
- throw error;
201
- }
202
-
203
- if (record.type !== "match" && record.type !== "context") continue;
204
- const data = record.data;
190
+ try { return JSON.parse(line); } catch (error) {
191
+ if (truncated && isLast) return undefined;
192
+ throw error;
193
+ }
194
+ }
205
195
 
206
- if (!data?.path?.text || !isString(data.lines?.text)) continue;
207
- const filePath = path.resolve(dir, data.path.text);
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;
208
199
 
209
- if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
210
- let candidate = candidates.get(filePath);
200
+ if (!data?.path?.text || !isString(data.lines?.text)) return;
201
+ const filePath = path.resolve(dir, data.path.text);
211
202
 
212
- if (!candidate) {
213
- candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
214
- candidates.set(filePath, candidate);
215
- }
203
+ if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) return;
204
+ let candidate = candidates.get(filePath);
216
205
 
217
- inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
206
+ if (!candidate) {
207
+ candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
208
+ candidates.set(filePath, candidate);
218
209
  }
219
210
 
211
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
212
+ }
213
+
214
+ function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
220
215
  let overlayTruncated = false;
221
216
 
222
217
  for (const filePath of pendingPaths) {
@@ -229,6 +224,52 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pen
229
224
  if (candidate.matched.size) candidates.set(filePath, candidate);
230
225
  }
231
226
 
227
+ return overlayTruncated;
228
+ }
229
+
230
+ function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
231
+ const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
232
+
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);
237
+
238
+ return args;
239
+ }
240
+
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 };
246
+
247
+ if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
248
+
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);
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);
272
+
232
273
  return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
233
274
  }
234
275
 
@@ -286,7 +327,7 @@ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
286
327
  if (large) out.push(location(candidate, root));
287
328
  else {
288
329
  try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
289
- catch (error) { signal?.throwIfAborted(); out.push(location(candidate, root)); }
330
+ catch { signal?.throwIfAborted(); out.push(location(candidate, root)); }
290
331
  }
291
332
  if (out.length >= MAX_ALTERNATIVES) break;
292
333
  }
@@ -294,7 +335,7 @@ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
294
335
  return out.slice(0, MAX_ALTERNATIVES);
295
336
  }
296
337
 
297
- export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
338
+ function admitSnapQuery(query, searchDir, root, includeHidden, pendingPaths) {
298
339
  const flags = tokenizeQuery(query);
299
340
 
300
341
  if (flags.tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
@@ -305,87 +346,78 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
305
346
  const dir = path.resolve(searchDir || process.cwd());
306
347
 
307
348
  if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
308
- signal?.throwIfAborted();
309
349
  flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
310
- pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
311
-
312
- const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
313
- const dirStat = await fs.stat(dir).catch(error => {
314
- if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
315
350
 
316
- return null;
317
- });
318
-
319
- if (!dirStat && !pendingPaths.length) return { ...empty, status: "not_found" };
320
- const diskFiles = dirStat?.isDirectory() === true;
321
- const focusFile = dirStat?.isFile() === true || pendingPaths.includes(dir) ? dir : null;
322
-
323
- const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
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
+ }
324
360
 
325
- if (!tokens.length) return { ...empty, status: "not_found" };
326
- const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile });
327
- // A declaration hit needs no prerequisite file listing or persistent index.
328
- // Bare names can name files, even when callers mention the same word.
329
- 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
+ }
330
365
 
331
- const listing = needsPaths && !search.truncated && diskFiles
332
- ? await run(["rg", "--files", "--null", ...(includeHidden ? ["--hidden"] : []), "-g", "!.git/**", "-g", "!**/.git/**", dir], { cwd: dir, signal, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS })
333
- : { 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;
334
370
 
335
- if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
371
+ return true;
372
+ }
336
373
 
337
- const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...(focusFile ? [focusFile] : []), ...pendingPaths])]
338
- .filter(file => inScope(file, dir, includeHidden));
374
+ function addFilenameCandidates(search, paths, { dir, focusFile, query, tokens, flags, exact }) {
339
375
  const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
376
+ const queryLower = query.toLowerCase();
340
377
 
341
378
  for (const filePath of paths) {
342
- if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
343
379
  const relative = path.relative(candidateRoot, filePath).toLowerCase();
344
380
 
345
- if (!tokens.some(token => relative.includes(token))) continue;
346
-
347
- if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
381
+ if (!filenameEligible(search, filePath, relative, tokens, exact, queryLower)) continue;
348
382
  const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
349
383
 
350
384
  if (focusFile || candidate.pathScore > 0) search.candidates.set(filePath, candidate);
351
385
  }
386
+ }
352
387
 
388
+ function rankSnapCandidates(search, tokenCount, focusFile) {
353
389
  const ranked = [];
354
390
 
355
391
  for (const candidate of search.candidates.values()) {
356
392
  if (focusFile || candidate.pathScore > -50) {
357
- ranked.push({ ...candidate, score: focusFile ? Math.max(1, rankScore(candidate, tokens.length)) : rankScore(candidate, tokens.length) });
393
+ const score = rankScore(candidate, tokenCount);
394
+ ranked.push({ ...candidate, score: focusFile ? Math.max(1, score) : score });
358
395
  }
359
396
  }
360
397
 
361
398
  ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
362
399
 
363
- const incomplete = search.truncated || listing.outputTruncated === true;
364
- const relativeRoot = root ?? dir;
365
- const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
366
-
367
- if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
368
-
369
- if (!ranked.length) {
370
- // Reuse bounded filename discovery; fuzzy rank never authorizes a source selection.
371
- const eligible = exact && query.length >= 4 && query.length <= 64;
372
- const limited = eligible && paths.length > 1024;
400
+ return ranked;
401
+ }
373
402
 
374
- const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
375
- { ...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
+ }
376
406
 
377
- if (fuzzy.length || limited) return { ...empty, status: limited ? "incomplete" : "ambiguous",
378
- candidates: fuzzy.map(hit => ({ path: hit.path, line: 1, context: [], match: "fuzzy" })),
379
- 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
+ }
380
410
 
381
- return { ...empty, status: "not_found" };
382
- }
411
+ function snapCoverage(best, tokens) {
412
+ return Math.max(best.matched.size, best.pathCoverage) / tokens.length;
413
+ }
383
414
 
415
+ async function decideSnapResult(ranked, tokens, empty, candidates, relativeRoot, overlayText, signal) {
384
416
  const best = ranked[0];
385
417
  const second = ranked[1];
386
418
  const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
387
- const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
388
- const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
419
+ const coverage = snapCoverage(best, tokens);
420
+ const uniqueExact = uniqueExactHit(best, second);
389
421
 
390
422
  if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
391
423
  return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText, signal) };
@@ -399,3 +431,75 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
399
431
 
400
432
  return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
401
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];
@@ -116,15 +116,24 @@ function declarationItem(line, rawLine, lineNumber) {
116
116
  return null;
117
117
  }
118
118
 
119
+ function skipJsNoise(line) {
120
+ return !line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*");
121
+ }
122
+
123
+ function jsItemAt(lines, i) {
124
+ const line = lines[i].trim();
125
+
126
+ if (skipJsNoise(line)) return null;
127
+ const indent = lines[i].length - lines[i].trimStart().length;
128
+
129
+ return declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
130
+ }
131
+
119
132
  function scanJavaScript(lines) {
120
133
  const items = [];
121
134
 
122
135
  for (let i = 0; i < lines.length; i++) {
123
- const line = lines[i].trim();
124
-
125
- if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
126
- const indent = lines[i].length - lines[i].trimStart().length;
127
- const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
136
+ const item = jsItemAt(lines, i);
128
137
 
129
138
  if (item) items.push(item);
130
139
  }
@@ -0,0 +1,31 @@
1
+ import { isString, isObject } from "../shared/decode.js";
2
+
3
+ const ARGV_ERROR = "bash argv requires a command string and an array of string args";
4
+
5
+ function quoteShellArg(value) {
6
+ return "'" + String(value).replaceAll("'", "'\\''") + "'";
7
+ }
8
+
9
+ /** Normalize guest bash(command, opts) / bash({command, args}) into host args. */
10
+ function normalizeArgv(args) {
11
+ if (args.args === undefined) return;
12
+ if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
13
+
14
+ for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error(ARGV_ERROR);
15
+ args.args = args.args.map(String);
16
+
17
+ if (process.platform === "win32") {
18
+ delete args._directArgv;
19
+ args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
20
+ delete args.args;
21
+ } else args._directArgv = true;
22
+ }
23
+
24
+ export function normalizeBash(command, opts) {
25
+ const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
26
+ normalizeArgv(args);
27
+
28
+ if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
29
+
30
+ return args;
31
+ }
@@ -0,0 +1,95 @@
1
+ import { isString, isObject, isFunction, isNumber } from "../shared/decode.js";
2
+
3
+ export const EDIT_USAGE = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
4
+
5
+ function spanStart(value) {
6
+ return isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
7
+ }
8
+
9
+ function spanEnd(value, start) {
10
+ return isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
11
+ }
12
+
13
+ export function viewSpan(value) {
14
+ const start = spanStart(value);
15
+ const end = spanEnd(value, start);
16
+
17
+ if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
18
+
19
+ return { start: Math.floor(start), end: Math.floor(end) };
20
+ }
21
+
22
+ export function isEditView(value) {
23
+ return isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
24
+ }
25
+
26
+ function classifyViewEdit(p, oldText, newText) {
27
+ if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
28
+ const span = viewSpan(p);
29
+ const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
30
+
31
+ if (newText !== undefined) args.oldText = oldText;
32
+
33
+ return { kind: "view", command: "edit", args };
34
+ }
35
+
36
+ function namedEditObject(p, oldText, newText) {
37
+ if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(EDIT_USAGE);
38
+
39
+ return p;
40
+ }
41
+
42
+ function namedEditPositional(p, oldText, newText) {
43
+ if (isObject(oldText) && !Array.isArray(oldText)) throw new Error(EDIT_USAGE);
44
+
45
+ return Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
46
+ }
47
+
48
+ function normalizeEditArgs(p, oldText, newText) {
49
+ return isObject(p) ? namedEditObject(p, oldText, newText) : namedEditPositional(p, oldText, newText);
50
+ }
51
+
52
+ function namedEditArgs(p, oldText, newText) {
53
+ const args = normalizeEditArgs(p, oldText, newText);
54
+
55
+ if (!isString(args.path) || !args.path.trim()) throw new Error(EDIT_USAGE);
56
+
57
+ return args;
58
+ }
59
+
60
+ function assertNamedEditMode(args, oldText, newText) {
61
+ const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
62
+
63
+ if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(EDIT_USAGE);
64
+ }
65
+
66
+ function classifyPatch(args) {
67
+ if (!isString(args.patch) || !args.patch.trim()) throw new Error(EDIT_USAGE);
68
+
69
+ return { kind: "patch", command: "apply_patch", args };
70
+ }
71
+
72
+ function classifyReplacements(args) {
73
+ const edits = args.edits === undefined ? [args] : args.edits;
74
+
75
+ if (!Array.isArray(edits) || !edits.length) throw new Error(EDIT_USAGE);
76
+
77
+ for (const e of edits) if (!isString(e?.oldText) || !e.oldText.length || !isString(e?.newText)) throw new Error(EDIT_USAGE + "; replacements require non-empty oldText and string newText");
78
+
79
+ return { kind: "edits", command: "edit", args };
80
+ }
81
+
82
+ function classifyNamedEdit(p, oldText, newText) {
83
+ const args = namedEditArgs(p, oldText, newText);
84
+ assertNamedEditMode(args, oldText, newText);
85
+
86
+ return args.patch !== undefined ? classifyPatch(args) : classifyReplacements(args);
87
+ }
88
+
89
+ /** Guest signature → { command, args } for one host call. */
90
+ export function classifyEdit(p, oldText, newText) {
91
+ if (isFunction(p)) return { kind: "checkpoint", fn: p };
92
+ if (isEditView(p) && isString(oldText) && (newText === undefined || isString(newText))) return classifyViewEdit(p, oldText, newText);
93
+
94
+ return classifyNamedEdit(p, oldText, newText);
95
+ }