pi-supernova 0.10.2 → 0.11.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 (81) hide show
  1. package/README.md +365 -90
  2. package/docs/CHANGELOG.md +89 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +38 -18
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +16 -4
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +69 -18
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. package/src/ui/trace.js +8 -5
@@ -27,9 +27,15 @@ function relevance(span, lower, stems) {
27
27
  return score;
28
28
  }
29
29
 
30
- function chooseExpanded(spans, lower, stems, raw, opts) {
31
- if (stems.length === 0) return new Set();
32
- const scored = spans.map((s, i) => ({ i, r: relevance(s, lower, stems), chars: raw.slice(s.start - 1, s.end).join("\n").length }));
30
+ function chooseExpanded(spans, lower, stems, raw, opts, about) {
31
+ const indexed = spans.map((s, i) => ({ s, i }));
32
+ const name = String(about ?? "").trim().toLowerCase();
33
+ const exact = indexed.filter(({ s }) => s.name.toLowerCase() === name);
34
+
35
+ if (stems.length === 0 && exact.length === 0) return new Set();
36
+ // Ancestors accumulate descendant hits, but must not outvote an exact symbol.
37
+ const candidates = exact.length ? exact : indexed;
38
+ const scored = candidates.map(({ s, i }) => ({ i, r: relevance(s, lower, stems), chars: raw.slice(s.start - 1, s.end).join("\n").length }));
33
39
  scored.sort((a, b) => b.r - a.r || a.i - b.i);
34
40
  const expanded = new Set();
35
41
  let budget = opts.maxChars;
@@ -54,11 +60,11 @@ function foldedLine(span) {
54
60
  return String(span.start).padStart(5) + " " + sig + (body > 0 ? " … " + body + " lines" : "");
55
61
  }
56
62
 
57
- function expandedBlock(span, raw, opts) {
63
+ function expandedBlock(span, raw, opts, names) {
58
64
  const out = [];
59
65
 
60
66
  for (let l = span.start; l <= span.end; l++) out.push(String(l).padStart(5) + " " + raw[l - 1]);
61
- const refs = opts.references ? opts.references(span.name, span.start) : [];
67
+ const refs = opts.references ? opts.references(span.name, span.start, names) : [];
62
68
 
63
69
  // Who uses this declaration: the relation a reader would otherwise grep for next.
64
70
  if (refs.length) out.push(" // used by: " + refs.slice(0, opts.maxRefs).join(", ") + (refs.length > opts.maxRefs ? " (+" + (refs.length - opts.maxRefs) + ")" : ""));
@@ -127,12 +133,30 @@ export function outlineFile(entry, relPath, about, options = {}) {
127
133
  const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
128
134
 
129
135
  if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
130
- const expanded = chooseExpanded(spans, lower, stems, raw, opts);
131
- const parts = outlineHeader(spans, raw, opts);
136
+ const expanded = chooseExpanded(spans, lower, stems, raw, opts, about);
137
+ const header = outlineHeader(spans, raw, opts);
138
+ const parts = [...header];
139
+ const focused = [];
140
+ const names = [...expanded].map(i => spans[i].name);
141
+
142
+ for (let i = 0; i < spans.length; i++) {
143
+ const body = expanded.has(i) ? expandedBlock(spans[i], raw, opts, names) : null;
144
+ parts.push(body ?? foldedLine(spans[i]));
145
+
146
+ if (body !== null) focused.push(body);
147
+ }
132
148
 
133
- for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
134
149
  const label = about ? String(about).replace(/\s+/g, " ").slice(0, 120) : "";
135
150
  const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (label ? " for \"" + label + "\"" : "") + " · read(path, line, count) for a folded body";
136
151
 
137
- return { text: clipOutline(title + "\n" + parts.join("\n"), title, opts.maxChars), expanded: expanded.size, declarations: spans.length };
152
+ let text = title + "\n" + parts.join("\n");
153
+
154
+ if (text.length > opts.maxChars && focused.length) {
155
+ // Navigation may be clipped; requested code must not lose to preceding folds.
156
+ const navigation = [...header, ...spans.map(foldedLine)];
157
+ text = title + "\n// focused bodies first; declaration map follows\n" + focused.join("\n")
158
+ + "\n// declaration map (bodies folded)\n" + navigation.join("\n");
159
+ }
160
+
161
+ return { text: clipOutline(text, title, opts.maxChars), expanded: expanded.size, declarations: spans.length };
138
162
  }
@@ -32,12 +32,15 @@ export function stem(token) {
32
32
  export function tokenizeQuery(query) {
33
33
  if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
34
34
  const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
35
+ // A document filename is one literal anchor, not a generic extension hit.
36
+ const document = /^[\w./\\-]+\.(?:md|mdx|rst|txt)$/i.test(query.trim());
35
37
 
36
38
  return {
37
- tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
39
+ tokens: document ? [query.trim().toLowerCase().replaceAll("\\", "/")]
40
+ : [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
38
41
  wantsTest: words.some(word => TEST_WORDS.has(word)),
39
42
  wantsType: words.some(word => TYPE_WORDS.has(word)),
40
- wantsDoc: words.some(word => DOC_WORDS.has(word)),
43
+ wantsDoc: document || words.some(word => DOC_WORDS.has(word)),
41
44
  };
42
45
  }
43
46
 
@@ -62,6 +65,7 @@ function topologyPenalty(normalized, flags) {
62
65
  const test = isTestPath(normalized);
63
66
 
64
67
  if (test && !flags.wantsTest) return -50;
68
+
65
69
  if (!test && flags.wantsTest) return -20;
66
70
  }
67
71
 
@@ -77,4 +81,5 @@ export function scorePathTopology(filePath, tokens, flags) {
77
81
 
78
82
  return score + tokenPathScore(path.basename(normalized), normalized.split(/[^a-zA-Z0-9]+/), normalized, tokens);
79
83
  }
84
+
80
85
  export { SOURCE_EXT, MAX_NEEDLE_CHARS };
@@ -1,5 +1,7 @@
1
1
  export {globToRegExp} from '../fs/glob.js';
2
+
2
3
  export {declaredName} from './source-entry.js';
4
+
3
5
  import {fromText,linesOf,spansOf,surfaceOf} from './source-entry.js';
4
6
  import * as fs from "node:fs";
5
7
  import * as path from "node:path";
@@ -24,6 +26,7 @@ const WATCH_DEBOUNCE_MS = 150;
24
26
  const MAX_INDEXED_FILES = 4000;
25
27
 
26
28
  const MAX_FILE_BYTES = 512 * 1024;
29
+
27
30
  const MAX_ENTRY_CACHE_BYTES = 64 * 1024 * 1024;
28
31
 
29
32
  const BINARY_EXT = new Set([
@@ -54,9 +57,9 @@ function parseRgFiles(res, root) {
54
57
 
55
58
  if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
56
59
  const truncated = res.outputTruncated === true;
57
- const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
60
+ const output = truncated && !res.stdout.endsWith("\0") ? res.stdout.slice(0, res.stdout.lastIndexOf("\0") + 1) : res.stdout;
58
61
 
59
- return { files: output.split("\n").flatMap(f => f ? [path.resolve(root, f)] : []).sort(), error, truncated, missing: false };
62
+ return { files: output.split("\0").flatMap(f => f ? [path.resolve(root, f)] : []).sort(), error, truncated, missing: false };
60
63
  }
61
64
 
62
65
  function addPorcelainRow(rows, i, set) {
@@ -95,7 +98,9 @@ function grepPendingHuge(pending, rel, regex, out) {
95
98
  const text = pending.slice(start, stop).replace(/\r$/, "");
96
99
 
97
100
  line++;
101
+
98
102
  if (regex.test(text)) out.push({ rel, line, text, def: false });
103
+
99
104
  if (end === -1) break;
100
105
  start = end + 1;
101
106
  }
@@ -223,7 +228,7 @@ export class WorkspaceIndex {
223
228
  }
224
229
 
225
230
  async listFilesWithRg(root, includeHidden, signal) {
226
- const args = ["rg", "--files"];
231
+ const args = ["rg", "--files", "--null"];
227
232
 
228
233
  if (includeHidden) args.push("--hidden");
229
234
  args.push("-g", "!.git/**", "-g", "!**/.git/**", "--", root);
@@ -16,4 +16,24 @@ function overlaySearchEntry(index, filePath, overlayText) {
16
16
  ? index.entry(filePath)
17
17
  : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
18
18
  }
19
+
20
+ // Resolve filesystem aliases once per distinct candidate, not per matching line.
21
+ // The snapshot is call-local; synchronous index/scoring code never sees promises.
22
+ export async function overlaySnapshot(files, overlayText, signal) {
23
+ const paths = [...new Set(files)], values = new Map();
24
+
25
+ for (let i = 0; i < paths.length; i += 8) {
26
+ signal?.throwIfAborted();
27
+ await Promise.all(paths.slice(i, i + 8).map(async file => {
28
+ const value = await overlayText(file);
29
+
30
+ if (value !== undefined) values.set(file, value);
31
+ }));
32
+ }
33
+
34
+ signal?.throwIfAborted();
35
+
36
+ return file => values.get(file);
37
+ }
38
+
19
39
  export { pendingInScope, overlaySearchEntry };
@@ -1,5 +1,5 @@
1
1
  import {textResult} from '../shared/result.js';
2
- import {pendingInScope,overlaySearchEntry} from './search-files.js';
2
+ import {pendingInScope,overlaySearchEntry,overlaySnapshot} from './search-files.js';
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { isString } from "../shared/decode.js";
@@ -36,7 +36,9 @@ function applyMatchRecord(record, root, overlayText, add) {
36
36
  if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
37
37
  }
38
38
 
39
- function ingestRgMatches(records, result, signal, root, overlayText, add) {
39
+ async function ingestRgMatches(records, result, signal, root, overlayText, add, pendingPaths) {
40
+ const matches = [];
41
+
40
42
  for (let i = 0; i < records.length; i++) {
41
43
  signal?.throwIfAborted();
42
44
  const parsed = parseMatchRecord(records[i], result.outputTruncated, i === records.length - 1);
@@ -44,8 +46,15 @@ function ingestRgMatches(records, result, signal, root, overlayText, add) {
44
46
  if (parsed.stop) break;
45
47
 
46
48
  if (parsed.skip) continue;
47
- applyMatchRecord(parsed.record, root, overlayText, add);
49
+
50
+ if (isRgMatch(parsed.record)) matches.push(parsed.record);
48
51
  }
52
+
53
+ const snapshot = await overlaySnapshot([...pendingPaths, ...matches.map(record => path.resolve(root, record.data.path.text))], overlayText, signal);
54
+
55
+ for (const record of matches) applyMatchRecord(record, root, snapshot, add);
56
+
57
+ return snapshot;
49
58
  }
50
59
 
51
60
  function ingestPendingRefs(root, pendingPaths, overlayText, add) {
@@ -75,8 +84,8 @@ export async function referencesForNames({ root, names, excludePath, overlayText
75
84
  { cwd: root, signal, timeoutMs: 5000, maxOutputChars: 65536 });
76
85
 
77
86
  if (result.exitCode !== 0 && result.exitCode !== 1) throw new Error(result.stderr.trim() || "reference search failed");
78
- ingestRgMatches(result.stdout.split("\n"), result, signal, root, overlayText, add);
79
- ingestPendingRefs(root, pendingPaths, overlayText, add);
87
+ const snapshot = await ingestRgMatches(result.stdout.split("\n"), result, signal, root, overlayText, add, pendingPaths);
88
+ ingestPendingRefs(root, pendingPaths, snapshot, add);
80
89
 
81
90
  return { references, incomplete: result.outputTruncated === true };
82
91
  }
@@ -88,6 +97,8 @@ function grepCaseSensitive(pattern, params) {
88
97
  function pushGrepFlags(args, pattern, params) {
89
98
  if (!grepCaseSensitive(pattern, params)) args.push("--ignore-case");
90
99
 
100
+ if (params?.literal === true) args.push("--fixed-strings");
101
+
91
102
  if (params?.glob) args.push("--glob", String(params.glob));
92
103
  const limit = params?.limit;
93
104
 
@@ -120,15 +131,16 @@ function listDirectRows(stat, searchDir, cwd, pending, matcher) {
120
131
  }
121
132
 
122
133
  function mergeListStdout(stdout, cwd, pendingMerged) {
123
- const diskRows = String(stdout || "").split("\n").filter(Boolean)
134
+ const diskRows = String(stdout || "").split("\0").filter(Boolean)
124
135
  .map(row => relativeSlash(cwd, path.isAbsolute(row) ? row : path.resolve(cwd, row)));
136
+
125
137
  const rows = [...new Set([...diskRows, ...pendingMerged])];
126
138
 
127
139
  return rows.length ? rows.join("\n") + "\n" : "";
128
140
  }
129
141
 
130
142
  async function listDisk(searchDir, pattern, cwd, signal) {
131
- const args = ["--files"];
143
+ const args = ["--files", "--null"];
132
144
 
133
145
  if (pattern) args.push("-g", pattern);
134
146
  const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
@@ -137,6 +149,7 @@ async function listDisk(searchDir, pattern, cwd, signal) {
137
149
  const findArgs = [searchDir];
138
150
 
139
151
  if (pattern) findArgs.push("-name", pattern);
152
+ findArgs.push("-print0");
140
153
  const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
141
154
 
142
155
  return { stdout: findRes.stdout, via: "find", outputTruncated: findRes.outputTruncated === true };
@@ -155,7 +168,7 @@ export async function listWithTools(searchDir, pattern, cwd, signal, pendingPath
155
168
  return textResult(rows.length ? rows.join("\n") + "\n" : "", { via: pending.length ? "vfs" : "file" });
156
169
  }
157
170
 
158
- const pendingMerged = pendingAbs.filter((_, i) => !matcher || matcher.test(pending[i]));
171
+ const pendingMerged = pending.filter(file => !matcher || matcher.test(file));
159
172
  const listed = await listDisk(searchDir, pattern, cwd, signal);
160
173
 
161
174
  return textResult(mergeListStdout(listed.stdout, cwd, pendingMerged), { via: listed.via, outputTruncated: listed.outputTruncated });
@@ -212,10 +225,11 @@ export async function grepIndexed(index, pattern, params, searchPath, cwd, overl
212
225
 
213
226
  if (!index.canScan(files)) return null;
214
227
  files = applyGlob(files, params, cwd);
228
+ overlayText = await overlaySnapshot(files, overlayText);
215
229
 
216
230
  if (!filesReadable(index, files, overlayText)) return null;
217
231
  const rows = index.grepRows(files, regex, cwd, overlayText);
218
- const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText) : rows;
232
+ const fallback = rows.length === 0 && params?.literal !== true && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText) : rows;
219
233
 
220
234
  return formatGrepRows(fallback, grepLimit(params));
221
235
  }
@@ -226,9 +240,10 @@ function grepLimit(params) {
226
240
 
227
241
  function grepRegex(pattern, params) {
228
242
  const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
243
+ const source = params?.literal === true ? pattern.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&") : pattern;
229
244
 
230
245
  try {
231
- return { regex: new RegExp(pattern, caseSensitive ? "" : "i"), caseSensitive };
246
+ return { regex: new RegExp(source, caseSensitive ? "" : "i"), caseSensitive };
232
247
  } catch {
233
248
  return null;
234
249
  }
@@ -1,5 +1,6 @@
1
1
  import * as path from 'node:path';
2
2
  import {isString} from '../shared/decode.js';
3
+ import {overlaySnapshot} from './search-files.js';
3
4
  import {truncateChars} from '../output/format.js';
4
5
  import {extractStructuralSurface} from './surface.js';
5
6
  import {scorePathTopology,stem,SOURCE_EXT,MAX_NEEDLE_CHARS} from './query.js';
@@ -43,6 +44,7 @@ function bestDeclaration(items, query, tokens, needles) {
43
44
  const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
44
45
 
45
46
  if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
47
+
46
48
  if (exact) break;
47
49
  }
48
50
 
@@ -102,6 +104,7 @@ function inspectOverlay(candidate, text, needles, query, tokens, signal) {
102
104
  const lower = row.toLowerCase();
103
105
  inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
104
106
  }
107
+
105
108
  start = end;
106
109
  line++;
107
110
  }
@@ -157,6 +160,7 @@ function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
157
160
 
158
161
  if (includeHidden) args.push("--hidden");
159
162
  args.push("-g", "!.git/**", "-g", "!**/.git/**");
163
+
160
164
  for (const needle of searchNeedles) args.push("-e", needle);
161
165
  args.push("--", focusFile ?? dir);
162
166
 
@@ -165,7 +169,8 @@ function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
165
169
 
166
170
  async function runContentSearch({ dir, includeHidden, searchNeedles, run, overlayText, signal, diskFiles, focusFile }) {
167
171
  const args = rgSearchArgs(includeHidden, searchNeedles, focusFile, dir);
168
- const response = diskFiles || (focusFile && overlayText(focusFile) === undefined)
172
+
173
+ const response = diskFiles || (focusFile && await overlayText(focusFile) === undefined)
169
174
  ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
170
175
  : { stdout: "", stderr: "", exitCode: 1 };
171
176
 
@@ -174,17 +179,33 @@ async function runContentSearch({ dir, includeHidden, searchNeedles, run, overla
174
179
  return response;
175
180
  }
176
181
 
177
- function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal) {
178
- const records = response.stdout.split("\n");
182
+ async function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal, pendingPaths) {
183
+ const records = response.stdout.split("\n"), parsed = [];
179
184
 
180
185
  for (let i = 0; i < records.length; i++) {
181
186
  if ((i & 127) === 0) signal?.throwIfAborted();
182
187
  const record = parseRgRecord(records[i], response.outputTruncated, i === records.length - 1);
183
188
 
184
189
  if (record === undefined) break;
190
+
185
191
  if (!record) continue;
186
- absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles);
192
+ parsed.push(record);
187
193
  }
194
+
195
+ const files = [];
196
+
197
+ for (const record of parsed) {
198
+ if ((record.type !== "match" && record.type !== "context") || !isString(record.data?.path?.text)) continue;
199
+ const file = path.resolve(dir, record.data.path.text);
200
+
201
+ if (inScope(file, dir, includeHidden)) files.push(file);
202
+ }
203
+
204
+ const snapshot = await overlaySnapshot([...pendingPaths, ...files], overlayText, signal);
205
+
206
+ for (const record of parsed) absorbRgHit(candidates, record, dir, includeHidden, snapshot, candidateRoot, query, tokens, flags, needles, candidateNeedles);
207
+
208
+ return snapshot;
188
209
  }
189
210
 
190
211
  async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
@@ -195,9 +216,10 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pen
195
216
  const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
196
217
  const candidates = new Map();
197
218
  const response = await runContentSearch({ dir, includeHidden, searchNeedles: [...new Set(needles)], run, overlayText, signal, diskFiles, focusFile });
198
- absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
199
- const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
219
+ const snapshot = await absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal, pendingPaths);
220
+ const overlayTruncated = overlayCandidates(candidates, pendingPaths, snapshot, candidateRoot, query, tokens, flags, needles, candidateNeedles, signal);
200
221
 
201
222
  return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
202
223
  }
224
+
203
225
  export { inScope, makeCandidate, contentCandidates, MAX_SEARCH_CHARS };
@@ -1,5 +1,6 @@
1
1
  import {inScope,makeCandidate,contentCandidates,MAX_SEARCH_CHARS} from './snap-search.js';
2
2
  import { tokenizeQuery, stem } from "./query.js";
3
+ import { looksLikePath } from "../shared/decode.js";
3
4
 
4
5
  export {tokenizeQuery,scorePathTopology,stem} from './query.js';
5
6
 
@@ -29,7 +30,7 @@ function location(candidate, root) {
29
30
  }
30
31
 
31
32
  async function spanCandidates(filePath, lines, root, overlayText, signal) {
32
- const staged = overlayText(filePath);
33
+ const staged = await overlayText(filePath);
33
34
  const rel = relativeSlash(root, filePath);
34
35
  let text = staged;
35
36
 
@@ -61,7 +62,7 @@ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
61
62
 
62
63
  for (const candidate of ranked) {
63
64
  const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
64
- const staged = overlayText(candidate.path);
65
+ const staged = await overlayText(candidate.path);
65
66
  let large = false;
66
67
 
67
68
  if (staged !== undefined) large = Buffer.byteLength(staged) > 512 * 1024;
@@ -234,9 +235,13 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
234
235
 
235
236
  async function rankSnapSearch({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile, root, pathContext, empty }) {
236
237
  const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile });
238
+
237
239
  // A declaration hit needs no prerequisite file listing or persistent index.
240
+ // Filename queries still need exact file candidates when references matched.
238
241
  // Bare names can name files, even when callers mention the same word.
239
- const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
242
+ const needsPaths = looksLikePath(query) || !search.candidates.size
243
+ || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
244
+
240
245
  const listing = await snapListing(needsPaths, search.truncated, diskFiles, includeHidden, dir, run, signal);
241
246
  const paths = listedSnapPaths(listing, dir, includeHidden, focusFile, pendingPaths);
242
247
  addFilenameCandidates(search, paths, { dir, focusFile, query, tokens, flags, exact });
@@ -1,5 +1,6 @@
1
1
  import * as path from 'node:path';
2
2
  import {extractStructuralSurface} from './surface.js';
3
+ import { braceBlockEndLine } from "../fs/check.js";
3
4
 
4
5
  const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
5
6
 
@@ -18,48 +19,83 @@ function lineIndent(raw, i) {
18
19
  return raw[i].length - raw[i].trimStart().length;
19
20
  }
20
21
 
21
- function pythonDeclarationEnd(raw, lower, start, lineCount) {
22
- const base = lineIndent(raw, start - 1);
23
- let end = start;
22
+ function pythonHeaderEnd(raw, start, lineCount, suite = true) {
23
+ let depth = 0;
24
+ let quote = "";
25
+ let escape = false;
24
26
 
25
- for (let i = start; i < lineCount; i++) {
26
- if (lower[i] === "") { end = i + 1; continue; }
27
- if (lineIndent(raw, i) <= base) break;
28
- end = i + 1;
29
- }
27
+ for (let i = start - 1; i < lineCount; i++) {
28
+ let statement = raw[i].trimEnd();
30
29
 
31
- return Math.min(end, lineCount);
32
- }
30
+ for (let j = 0; j < statement.length; j++) {
31
+ const ch = statement[j];
33
32
 
34
- function braceDelta(text) {
35
- let depth = 0;
33
+ if (quote) {
34
+ if (escape) { escape = false; continue; }
36
35
 
37
- for (const ch of text) {
38
- if (ch === "{") depth++;
39
- else if (ch === "}") depth--;
40
- }
36
+ if (ch === "\\") { escape = true; continue; }
41
37
 
42
- return depth;
43
- }
38
+ if (statement.startsWith(quote, j)) {
39
+ j += quote.length - 1;
40
+ quote = "";
41
+ }
42
+
43
+ continue;
44
+ }
44
45
 
45
- function braceDeclarationEnd(raw, start, lineCount) {
46
- let depth = braceDelta(raw[start - 1] ?? "");
46
+ // Comments only start outside strings; triple-quoted payload persists
47
+ // across physical lines and closes on its full delimiter.
48
+ if (ch === "#") { statement = statement.slice(0, j).trimEnd(); break; }
47
49
 
48
- if (depth <= 0) return start;
50
+ if (ch === "'" || ch === "\"") {
51
+ quote = statement.startsWith(ch.repeat(3), j) ? ch.repeat(3) : ch;
52
+ j += quote.length - 1;
53
+ }
54
+ else if ("([{".includes(ch)) depth++;
55
+ else if (")]}".includes(ch)) depth--;
56
+ else if (suite && ch === ":" && depth === 0) return i + 1;
57
+ }
49
58
 
50
- for (let i = start; i < raw.length; i++) {
51
- depth += braceDelta(raw[i]);
59
+ // A backslash escaping a physical newline does not escape the next line's
60
+ // first character; the quote itself still persists.
61
+ escape = false;
52
62
 
53
- if (depth <= 0) return i + 1;
63
+ // Assignments end at a logical newline, not at a suite-opening colon.
64
+ if (!suite && depth === 0 && !quote && !statement.endsWith("\\")) return i + 1;
54
65
  }
55
66
 
56
67
  return lineCount;
57
68
  }
58
69
 
59
- function declarationEnd(raw, lower, start, lineCount, ext) {
60
- if (ext === ".py") return pythonDeclarationEnd(raw, lower, start, lineCount);
70
+ function pythonDeclarationEnd(raw, lower, start, lineCount) {
71
+ const base = lineIndent(raw, start - 1);
72
+ // Only a top-level colon ends a header, including a header with an inline
73
+ // suite. Colons in annotations, defaults and comments are not terminators.
74
+ let end = pythonHeaderEnd(raw, start, lineCount);
75
+
76
+ for (let i = end; i < lineCount; i++) {
77
+ // Blank and comment-only lines do not dedent a Python suite. Include them
78
+ // only when a later body line extends the span, preserving neighbor comments.
79
+ if (lower[i] === "" || lower[i].startsWith("#")) continue;
80
+
81
+ if (lineIndent(raw, i) <= base) break;
82
+ // Dedentation inside a continued expression or string is payload, not a
83
+ // suite boundary. Resume indentation checks after its logical newline.
84
+ end = pythonHeaderEnd(raw, i + 1, lineCount, false);
85
+ i = end - 1;
86
+ }
87
+
88
+ return Math.min(end, lineCount);
89
+ }
90
+
91
+ const BLOCK_DECLARATIONS = new Set(["function", "function*", "method", "class", "fn", "struct", "enum", "trait", "impl", "interface"]);
92
+
93
+ function declarationEnd(raw, lower, start, lineCount, ext, kind) {
94
+ if (String(ext ?? "").toLowerCase() === ".py") return kind === "constant"
95
+ ? pythonHeaderEnd(raw, start, lineCount, false)
96
+ : pythonDeclarationEnd(raw, lower, start, lineCount);
61
97
 
62
- return braceDeclarationEnd(raw, start, lineCount);
98
+ return Math.min(lineCount, Math.max(start, braceBlockEndLine(raw.join("\n"), start, ext, BLOCK_DECLARATIONS.has(kind))));
63
99
  }
64
100
 
65
101
  export function fromText(filePath, text) {
@@ -94,7 +130,7 @@ export function spansOf(entry) {
94
130
 
95
131
  for (let i = 0; i < items.length; i++) {
96
132
  const start = items[i].line;
97
- let end = declarationEnd(raw, lower, start, lineCount, entry.ext);
133
+ let end = declarationEnd(raw, lower, start, lineCount, entry.ext, items[i].kind);
98
134
 
99
135
  while (end > start && lower[end - 1] === "") end--;
100
136
  spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
@@ -6,6 +6,7 @@ export function pickSpan(spans, { line, name } = {}) {
6
6
  const named = needle ? spans.filter(item => item.name.toLowerCase() === needle) : [];
7
7
 
8
8
  if (named.length === 1) return named[0];
9
+
9
10
  if (!line) return;
10
11
 
11
12
  return spans.find(item => item.start === line)
@@ -39,11 +39,7 @@ export function normalizeBash(command, opts) {
39
39
  if (args.action !== undefined || args.sessionId !== undefined) return normalizeTerminalControl(args);
40
40
  assertBashOptions(args);
41
41
 
42
- for (const key of ["background", "pty"]) {
43
- if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error(`bash ${key} must be boolean`);
44
- }
45
-
46
- if (args.pty !== undefined && args.background !== true) throw new Error("bash pty requires background:true");
42
+ assertLaunchFlags(args);
47
43
 
48
44
  if (!isString(args.command) || !args.command.trim()) throw new Error("bash requires a non-empty command string");
49
45
 
@@ -55,6 +51,15 @@ export function normalizeBash(command, opts) {
55
51
  return args;
56
52
  }
57
53
 
54
+ function assertLaunchFlags(args) {
55
+ for (const key of ["background", "pty"]) {
56
+ if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error(`bash ${key} must be boolean`);
57
+ }
58
+
59
+ if (args.pty !== undefined && args.background !== true) throw new Error("bash pty requires background:true");
60
+
61
+ }
62
+
58
63
  function normalizeTerminalControl(args) {
59
64
  const fields = {
60
65
  list: ["action"],
@@ -72,11 +77,15 @@ function normalizeTerminalControl(args) {
72
77
 
73
78
  if (args.action === "write" && (!isString(args.input) || args.input.length > 16384)) throw new Error("bash terminal input must be a string of at most 16384 characters");
74
79
 
80
+ assertTerminalNumbers(args);
81
+
82
+ return args;
83
+ }
84
+
85
+ function assertTerminalNumbers(args) {
75
86
  if (args.cursor !== undefined && (!Number.isSafeInteger(args.cursor) || args.cursor < 0)) throw new Error("bash terminal cursor must be a non-negative safe integer");
76
87
 
77
88
  if (args.waitMs !== undefined && (!Number.isInteger(args.waitMs) || args.waitMs < 0 || args.waitMs > 30000)) throw new Error("bash terminal waitMs must be an integer from 0 to 30000");
78
-
79
- return args;
80
89
  }
81
90
 
82
91
  function normalizeTimeout(args) {