pi-supernova 0.3.2 → 0.4.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.
- package/README.md +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +62 -0
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/context/search.js
CHANGED
|
@@ -16,51 +16,71 @@ function textResult(text, details) {
|
|
|
16
16
|
export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
|
|
17
17
|
const references = new Map(names.map(name => [name, []]));
|
|
18
18
|
const patterns = names.map(name => new RegExp("(?<![\\w$])" + name.replaceAll("$", "\\$") + "(?![\\w$])"));
|
|
19
|
+
|
|
19
20
|
const add = (file, line, text) => {
|
|
20
21
|
if (file === excludePath) return;
|
|
22
|
+
|
|
21
23
|
for (let i = 0; i < names.length; i++) {
|
|
22
24
|
const hits = references.get(names[i]);
|
|
25
|
+
|
|
23
26
|
if (hits.length < 7 && patterns[i].test(text)) hits.push(relativeSlash(root, file) + ":" + line);
|
|
24
27
|
}
|
|
25
28
|
};
|
|
29
|
+
|
|
26
30
|
const result = await run(["rg", "--json", "--fixed-strings", ...names.flatMap(name => ["-e", name]), "--", root],
|
|
27
31
|
{ cwd: root, signal, timeoutMs: 5000, maxOutputChars: 65536 });
|
|
32
|
+
|
|
28
33
|
if (result.exitCode !== 0 && result.exitCode !== 1) throw new Error(result.stderr.trim() || "reference search failed");
|
|
29
34
|
const records = result.stdout.split("\n");
|
|
35
|
+
|
|
30
36
|
for (let i = 0; i < records.length; i++) {
|
|
31
37
|
signal?.throwIfAborted();
|
|
38
|
+
|
|
32
39
|
if (!records[i]) continue;
|
|
33
40
|
let record;
|
|
41
|
+
|
|
34
42
|
try { record = JSON.parse(records[i]); }
|
|
35
43
|
catch (error) { if (result.outputTruncated && i === records.length - 1) break; throw error; }
|
|
44
|
+
|
|
36
45
|
if (record.type !== "match" || !isString(record.data?.path?.text) || !isString(record.data.lines?.text)) continue;
|
|
37
46
|
const file = path.resolve(root, record.data.path.text);
|
|
47
|
+
|
|
38
48
|
if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
|
|
39
49
|
}
|
|
50
|
+
|
|
40
51
|
for (const file of pendingPaths) {
|
|
41
52
|
const text = overlayText(file);
|
|
53
|
+
|
|
42
54
|
if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
|
|
43
55
|
}
|
|
56
|
+
|
|
44
57
|
return { references, incomplete: result.outputTruncated === true };
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
export function rgGrepArgs(pattern, params, searchPath) {
|
|
48
61
|
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
62
|
+
|
|
49
63
|
if (params?.caseSensitive !== true) args.push("--ignore-case");
|
|
64
|
+
|
|
50
65
|
if (params?.glob) args.push("--glob", String(params.glob));
|
|
51
66
|
args.push("--", pattern, searchPath);
|
|
67
|
+
|
|
52
68
|
return args;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
/** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
|
|
56
72
|
export async function listWithTools(searchDir, pattern, cwd, signal) {
|
|
57
73
|
const args = ["--files"];
|
|
74
|
+
|
|
58
75
|
if (pattern) args.push("-g", pattern);
|
|
59
76
|
const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
|
|
77
|
+
|
|
60
78
|
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
|
|
61
79
|
const findArgs = [searchDir];
|
|
80
|
+
|
|
62
81
|
if (pattern) findArgs.push("-name", pattern);
|
|
63
82
|
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
83
|
+
|
|
64
84
|
return textResult(findRes.stdout, { via: "find" });
|
|
65
85
|
}
|
|
66
86
|
|
|
@@ -73,6 +93,7 @@ const GLOB_CHARS = /[*?[\]{}]/;
|
|
|
73
93
|
export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
74
94
|
if (!pattern || GLOB_CHARS.test(pattern)) return null;
|
|
75
95
|
const files = await index.files(root);
|
|
96
|
+
|
|
76
97
|
if (!index.canScan(files)) return null;
|
|
77
98
|
const rel = files.map((f) => relativeSlash(cwd, f));
|
|
78
99
|
const absolute = new Map(rel.map((r, i) => [r, files[i]]));
|
|
@@ -81,23 +102,30 @@ export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
|
81
102
|
const ranked = rankPaths(pattern, rel, { frecency: index.frecency, mtimeOf, modified: await index.modifiedFiles(cwd), currentFile: index.lastTouched });
|
|
82
103
|
// fff weak-match detector: when nothing matches exactly and the best is mostly typos, say so instead of flooding.
|
|
83
104
|
const rows = ranked.slice(0, limit);
|
|
105
|
+
|
|
84
106
|
if (rows.length === 0) return "";
|
|
107
|
+
|
|
85
108
|
return rows.map((r) => r.path).join("\n") + "\n";
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
/** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
|
|
89
112
|
export async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
90
113
|
const compiled = grepRegex(pattern, params);
|
|
114
|
+
|
|
91
115
|
if (!compiled) return null;
|
|
92
116
|
const { regex, caseSensitive } = compiled;
|
|
93
117
|
let files = await index.files(searchPath);
|
|
118
|
+
|
|
94
119
|
if (!index.canScan(files)) return null;
|
|
120
|
+
|
|
95
121
|
if (params?.glob) {
|
|
96
122
|
const matcher = globToRegExp(String(params.glob));
|
|
97
123
|
files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
|
|
98
124
|
}
|
|
125
|
+
|
|
99
126
|
const rows = index.grepRows(files, regex, cwd);
|
|
100
127
|
const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
|
|
128
|
+
|
|
101
129
|
return formatGrepRows(fallback, grepLimit(params));
|
|
102
130
|
}
|
|
103
131
|
|
|
@@ -107,6 +135,7 @@ function grepLimit(params) {
|
|
|
107
135
|
|
|
108
136
|
function grepRegex(pattern, params) {
|
|
109
137
|
const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
|
|
138
|
+
|
|
110
139
|
try {
|
|
111
140
|
return { regex: new RegExp(pattern, caseSensitive ? "" : "i"), caseSensitive };
|
|
112
141
|
} catch {
|
|
@@ -118,17 +147,22 @@ function grepRegex(pattern, params) {
|
|
|
118
147
|
function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
|
|
119
148
|
const maxTypos = pattern.length >= 8 ? 2 : 1;
|
|
120
149
|
const rows = [];
|
|
150
|
+
|
|
121
151
|
for (const filePath of files) {
|
|
122
152
|
const e = index.entry(filePath);
|
|
153
|
+
|
|
123
154
|
if (!e) continue;
|
|
124
155
|
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
125
156
|
const rel = relativeSlash(cwd, filePath);
|
|
157
|
+
|
|
126
158
|
for (let i = 0; i < raw.length && rows.length <= 400; i++) {
|
|
127
159
|
const m = fuzzyMatch(pattern, raw[i], { maxTypos, caseSensitive });
|
|
160
|
+
|
|
128
161
|
if (!m || m.end - m.start > pattern.length + 2) continue;
|
|
129
162
|
rows.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && fuzzyMatch(pattern, defNames[i], { maxTypos }) !== null });
|
|
130
163
|
}
|
|
131
164
|
}
|
|
165
|
+
|
|
132
166
|
return rows;
|
|
133
167
|
}
|
|
134
168
|
|
|
@@ -136,39 +170,50 @@ function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
|
|
|
136
170
|
function formatGrepRows(rows, limit) {
|
|
137
171
|
if (rows.length === 0) return "";
|
|
138
172
|
const groups = new Map();
|
|
173
|
+
|
|
139
174
|
for (const r of rows) {
|
|
140
175
|
if (!groups.has(r.rel)) groups.set(r.rel, []);
|
|
141
176
|
groups.get(r.rel).push(r);
|
|
142
177
|
}
|
|
178
|
+
|
|
143
179
|
const files = [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
|
|
144
180
|
let out = "";
|
|
145
181
|
let shown = 0;
|
|
182
|
+
|
|
146
183
|
for (const group of files) {
|
|
147
184
|
if (shown >= limit) break;
|
|
148
185
|
out += group[0].rel + "\n";
|
|
149
186
|
group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
|
|
187
|
+
|
|
150
188
|
for (const r of group) {
|
|
151
189
|
if (shown++ >= limit) break;
|
|
152
190
|
out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
|
|
153
191
|
}
|
|
154
192
|
}
|
|
193
|
+
|
|
155
194
|
if (rows.length > limit) out += "… " + (rows.length - limit) + " more matches (pass limit or narrow the pattern)\n";
|
|
195
|
+
|
|
156
196
|
return out;
|
|
157
197
|
}
|
|
158
198
|
|
|
159
199
|
/** rg --files [-g pattern] served from the index; null when the tree is too large. */
|
|
160
200
|
export async function listIndexed(index, root, cwd, pattern) {
|
|
161
201
|
const files = await index.files(root);
|
|
202
|
+
|
|
162
203
|
if (!index.canScan(files)) return null;
|
|
163
204
|
const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
|
|
205
|
+
|
|
164
206
|
if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
|
|
165
207
|
let matcher;
|
|
208
|
+
|
|
166
209
|
try {
|
|
167
210
|
matcher = globToRegExp(pattern);
|
|
168
211
|
} catch {
|
|
169
212
|
return null;
|
|
170
213
|
}
|
|
214
|
+
|
|
171
215
|
const hits = rel.filter((f) => matcher.test(f));
|
|
216
|
+
|
|
172
217
|
return hits.length ? hits.join("\n") + "\n" : "";
|
|
173
218
|
}
|
|
174
219
|
|
package/src/context/snap.js
CHANGED
|
@@ -11,20 +11,26 @@ const STOP_WORDS = new Set([
|
|
|
11
11
|
"by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
|
|
12
12
|
"file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
|
|
13
13
|
]);
|
|
14
|
+
|
|
14
15
|
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
|
|
16
|
+
|
|
15
17
|
const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
|
|
18
|
+
|
|
16
19
|
const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
|
|
20
|
+
|
|
17
21
|
const MAX_ALTERNATIVES = 3;
|
|
18
22
|
|
|
19
23
|
/** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
|
|
20
24
|
export function stem(token) {
|
|
21
25
|
if (token.length < 5) return token;
|
|
26
|
+
|
|
22
27
|
return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export function tokenizeQuery(query) {
|
|
26
31
|
if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
|
|
27
32
|
const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
|
|
33
|
+
|
|
28
34
|
return {
|
|
29
35
|
tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
|
|
30
36
|
wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
|
|
@@ -36,28 +42,36 @@ export function tokenizeQuery(query) {
|
|
|
36
42
|
export function scorePathTopology(filePath, tokens, flags) {
|
|
37
43
|
const normalized = filePath.replaceAll("\\", "/").toLowerCase();
|
|
38
44
|
const parts = normalized.split("/");
|
|
45
|
+
|
|
39
46
|
if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
|
|
40
47
|
const test = isTestPath(normalized);
|
|
48
|
+
|
|
41
49
|
if (test && !flags.wantsTest) return -50;
|
|
50
|
+
|
|
42
51
|
if (!test && flags.wantsTest) return -20;
|
|
43
52
|
const base = path.basename(normalized);
|
|
44
53
|
const words = normalized.split(/[^a-zA-Z0-9]+/);
|
|
45
54
|
const ext = path.extname(normalized);
|
|
46
55
|
let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
|
|
56
|
+
|
|
47
57
|
if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
|
|
58
|
+
|
|
48
59
|
for (const token of tokens) {
|
|
49
60
|
if (base === token || base.startsWith(token + ".")) score += 60;
|
|
50
61
|
else if (base.includes(token)) score += 30;
|
|
51
62
|
else if (words.includes(token)) score += 15;
|
|
52
63
|
else if (normalized.includes(token)) score += 5;
|
|
53
64
|
}
|
|
65
|
+
|
|
54
66
|
return score;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
function inScope(filePath, dir, includeHidden) {
|
|
58
70
|
const relative = path.relative(dir, filePath);
|
|
71
|
+
|
|
59
72
|
if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
|
|
60
73
|
const parts = relative.split(path.sep);
|
|
74
|
+
|
|
61
75
|
return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
|
|
62
76
|
}
|
|
63
77
|
|
|
@@ -65,8 +79,10 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
|
65
79
|
const relative = path.relative(dir, filePath);
|
|
66
80
|
const lower = relative.toLowerCase();
|
|
67
81
|
const base = path.basename(lower);
|
|
82
|
+
|
|
68
83
|
const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
|
|
69
84
|
|| base.slice(0, -path.extname(base).length) === query.toLowerCase();
|
|
85
|
+
|
|
70
86
|
return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
|
|
71
87
|
pathCoverage: tokens.filter(token => lower.includes(token)).length,
|
|
72
88
|
matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
|
|
@@ -76,22 +92,29 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
|
76
92
|
function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
77
93
|
const text = raw.replace(/\r?\n$/, "");
|
|
78
94
|
const lower = text.toLowerCase();
|
|
95
|
+
|
|
79
96
|
if (isMatch) {
|
|
80
97
|
const matches = tokens.filter(token => lower.includes(token));
|
|
98
|
+
|
|
81
99
|
for (const token of matches) candidate.matched.add(token);
|
|
82
100
|
const ext = path.extname(candidate.path).toLowerCase();
|
|
83
101
|
const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
|
|
84
102
|
let declaration;
|
|
85
103
|
let definitionCoverage = 0;
|
|
86
104
|
let exact = false;
|
|
105
|
+
|
|
87
106
|
for (const item of items) {
|
|
88
107
|
const name = item.name.toLowerCase();
|
|
89
108
|
const itemExact = name === query.toLowerCase();
|
|
90
109
|
const coverage = tokens.filter(token => name.includes(token)).length;
|
|
110
|
+
|
|
91
111
|
if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
|
|
112
|
+
|
|
92
113
|
if (exact) break;
|
|
93
114
|
}
|
|
115
|
+
|
|
94
116
|
const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
|
|
117
|
+
|
|
95
118
|
if (score > candidate.anchorScore) {
|
|
96
119
|
candidate.anchorScore = score;
|
|
97
120
|
candidate.line = lineNumber;
|
|
@@ -100,63 +123,86 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
|
100
123
|
candidate.definitionCoverage = definitionCoverage;
|
|
101
124
|
candidate.lineCoverage = matches.length;
|
|
102
125
|
candidate.context.clear();
|
|
126
|
+
|
|
103
127
|
for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
|
|
104
128
|
}
|
|
105
129
|
}
|
|
130
|
+
|
|
106
131
|
const excerpt = truncateChars(text, 240, "source line").text;
|
|
132
|
+
|
|
107
133
|
if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
|
|
108
134
|
candidate.recent.push([lineNumber, excerpt]);
|
|
135
|
+
|
|
109
136
|
if (candidate.recent.length > 2) candidate.recent.shift();
|
|
110
137
|
}
|
|
111
138
|
|
|
112
139
|
function inspectOverlay(candidate, text, needles, query, tokens) {
|
|
113
140
|
const lines = text.split("\n");
|
|
114
141
|
const matches = [];
|
|
142
|
+
|
|
115
143
|
for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
|
|
144
|
+
|
|
116
145
|
for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
|
|
117
146
|
candidate.context.clear();
|
|
147
|
+
|
|
118
148
|
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);
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
|
|
122
152
|
const needles = exact ? [query.toLowerCase()] : tokens;
|
|
123
153
|
const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
|
|
154
|
+
|
|
124
155
|
if (includeHidden) args.push("--hidden");
|
|
125
156
|
args.push("-g", "!.git/**", "-g", "!**/.git/**");
|
|
157
|
+
|
|
126
158
|
for (const needle of needles) args.push("-e", needle);
|
|
127
159
|
args.push("--", dir);
|
|
160
|
+
|
|
128
161
|
const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
|
|
129
162
|
: { stdout: "", stderr: "", exitCode: 1 };
|
|
163
|
+
|
|
130
164
|
if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
|
|
131
165
|
const candidates = new Map();
|
|
132
166
|
const records = response.stdout.split("\n");
|
|
167
|
+
|
|
133
168
|
for (let i = 0; i < records.length; i++) {
|
|
134
169
|
if ((i & 127) === 0) signal?.throwIfAborted();
|
|
170
|
+
|
|
135
171
|
if (!records[i]) continue;
|
|
136
172
|
let record;
|
|
173
|
+
|
|
137
174
|
try { record = JSON.parse(records[i]); } catch (error) {
|
|
138
175
|
if (response.outputTruncated && i === records.length - 1) break;
|
|
139
176
|
throw error;
|
|
140
177
|
}
|
|
178
|
+
|
|
141
179
|
if (record.type !== "match" && record.type !== "context") continue;
|
|
142
180
|
const data = record.data;
|
|
181
|
+
|
|
143
182
|
if (!data?.path?.text || !isString(data.lines?.text)) continue;
|
|
144
183
|
const filePath = path.resolve(dir, data.path.text);
|
|
184
|
+
|
|
145
185
|
if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
|
|
146
186
|
let candidate = candidates.get(filePath);
|
|
187
|
+
|
|
147
188
|
if (!candidate) {
|
|
148
189
|
candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
149
190
|
candidates.set(filePath, candidate);
|
|
150
191
|
}
|
|
192
|
+
|
|
151
193
|
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
|
|
152
194
|
}
|
|
195
|
+
|
|
153
196
|
for (const filePath of pendingPaths) {
|
|
154
197
|
const pending = overlayText(filePath);
|
|
198
|
+
|
|
155
199
|
if (pending === undefined) continue;
|
|
156
200
|
const candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
157
201
|
inspectOverlay(candidate, pending, needles, query, tokens);
|
|
202
|
+
|
|
158
203
|
if (candidate.matched.size) candidates.set(filePath, candidate);
|
|
159
204
|
}
|
|
205
|
+
|
|
160
206
|
return { candidates, truncated: response.outputTruncated === true };
|
|
161
207
|
}
|
|
162
208
|
|
|
@@ -169,6 +215,7 @@ function rankScore(candidate, tokenCount) {
|
|
|
169
215
|
|
|
170
216
|
function location(candidate, root) {
|
|
171
217
|
const context = candidate.context;
|
|
218
|
+
|
|
172
219
|
return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
|
|
173
220
|
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
174
221
|
}
|
|
@@ -176,62 +223,91 @@ function location(candidate, root) {
|
|
|
176
223
|
export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
|
|
177
224
|
const flags = tokenizeQuery(query);
|
|
178
225
|
const tokens = [...new Set(flags.tokens.map(stem))];
|
|
226
|
+
|
|
179
227
|
if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
|
|
228
|
+
|
|
180
229
|
if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
|
|
181
230
|
query = query.trim();
|
|
182
231
|
const dir = path.resolve(searchDir || process.cwd());
|
|
232
|
+
|
|
183
233
|
if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
|
|
184
234
|
signal?.throwIfAborted();
|
|
185
235
|
flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
|
|
186
236
|
pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
|
|
237
|
+
|
|
187
238
|
const diskFiles = await fs.stat(dir).then(stat => stat.isDirectory(), error => {
|
|
188
239
|
if (error.code !== "ENOENT" || !pendingPaths.length) throw error;
|
|
240
|
+
|
|
189
241
|
return false;
|
|
190
242
|
});
|
|
243
|
+
|
|
191
244
|
const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
|
|
192
245
|
const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
|
|
193
246
|
const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles });
|
|
194
247
|
// A declaration hit needs no prerequisite file listing or persistent index.
|
|
195
248
|
// Bare names can name files, even when callers mention the same word.
|
|
196
249
|
const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
|
|
250
|
+
|
|
197
251
|
const listing = needsPaths && !search.truncated && diskFiles
|
|
198
252
|
? await run(["rg", "--files", "--null", ...(includeHidden ? ["--hidden"] : []), "-g", "!.git/**", "-g", "!**/.git/**", dir], { cwd: dir, signal, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS })
|
|
199
253
|
: { stdout: "", exitCode: 1 };
|
|
254
|
+
|
|
200
255
|
if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
|
|
201
|
-
|
|
256
|
+
|
|
257
|
+
const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...pendingPaths])]
|
|
202
258
|
.filter(file => inScope(file, dir, includeHidden));
|
|
259
|
+
|
|
203
260
|
for (const filePath of paths) {
|
|
204
261
|
if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
|
|
205
262
|
const relative = path.relative(dir, filePath).toLowerCase();
|
|
263
|
+
|
|
206
264
|
if (!tokens.some(token => relative.includes(token))) continue;
|
|
265
|
+
|
|
207
266
|
if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
|
|
208
267
|
const candidate = makeCandidate(filePath, dir, query, tokens, flags);
|
|
268
|
+
|
|
209
269
|
if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
|
|
210
270
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
271
|
+
|
|
272
|
+
const ranked = [];
|
|
273
|
+
|
|
274
|
+
for (const candidate of search.candidates.values()) {
|
|
275
|
+
if (candidate.pathScore > -50) {
|
|
276
|
+
ranked.push({ ...candidate, score: rankScore(candidate, tokens.length) });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
281
|
+
|
|
214
282
|
const incomplete = search.truncated || listing.outputTruncated === true;
|
|
215
283
|
const relativeRoot = root ?? dir;
|
|
216
284
|
const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
|
|
285
|
+
|
|
217
286
|
if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
|
|
287
|
+
|
|
218
288
|
if (!ranked.length) {
|
|
219
289
|
// Reuse bounded filename discovery; fuzzy rank never authorizes a source selection.
|
|
220
290
|
const eligible = exact && query.length >= 4 && query.length <= 64;
|
|
221
291
|
const limited = eligible && paths.length > 1024;
|
|
292
|
+
|
|
222
293
|
const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
|
|
223
294
|
{ ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
|
|
295
|
+
|
|
224
296
|
if (fuzzy.length || limited) return { ...empty, status: limited ? "incomplete" : "ambiguous",
|
|
225
297
|
candidates: fuzzy.map(hit => ({ path: hit.path, line: 1, context: [], match: "fuzzy" })),
|
|
226
298
|
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." };
|
|
299
|
+
|
|
227
300
|
return { ...empty, status: "not_found" };
|
|
228
301
|
}
|
|
302
|
+
|
|
229
303
|
const best = ranked[0];
|
|
230
304
|
const second = ranked[1];
|
|
231
305
|
const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
|
|
232
306
|
const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
|
|
233
307
|
const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
|
|
308
|
+
|
|
234
309
|
if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
|
|
235
310
|
const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
|
|
311
|
+
|
|
236
312
|
return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
|
|
237
313
|
}
|
package/src/context/surface.js
CHANGED
|
@@ -3,9 +3,11 @@ import { isString } from "../shared/decode.js";
|
|
|
3
3
|
|
|
4
4
|
function scanPython(lines) {
|
|
5
5
|
const items = [];
|
|
6
|
+
|
|
6
7
|
for (let i = 0; i < lines.length; i++) {
|
|
7
8
|
const line = lines[i];
|
|
8
9
|
const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
|
|
10
|
+
|
|
9
11
|
if (!match) continue;
|
|
10
12
|
items.push({
|
|
11
13
|
kind: match[2].includes("def") ? "function" : "class",
|
|
@@ -15,14 +17,17 @@ function scanPython(lines) {
|
|
|
15
17
|
depth: Math.floor(match[1].length / 4),
|
|
16
18
|
});
|
|
17
19
|
}
|
|
20
|
+
|
|
18
21
|
return items;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
function scanRust(lines) {
|
|
22
25
|
const items = [];
|
|
26
|
+
|
|
23
27
|
for (let i = 0; i < lines.length; i++) {
|
|
24
28
|
const line = lines[i].trim();
|
|
25
29
|
const match = /^(pub\s+)?(async\s+)?(fn|struct|enum|trait|impl|type|const)\s+([a-zA-Z0-9_]+)(<.*?>)?(\(.*?\))?/.exec(line);
|
|
30
|
+
|
|
26
31
|
if (!match) continue;
|
|
27
32
|
items.push({
|
|
28
33
|
kind: match[3],
|
|
@@ -31,14 +36,17 @@ function scanRust(lines) {
|
|
|
31
36
|
line: i + 1,
|
|
32
37
|
});
|
|
33
38
|
}
|
|
39
|
+
|
|
34
40
|
return items;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
function scanGo(lines) {
|
|
38
44
|
const items = [];
|
|
45
|
+
|
|
39
46
|
for (let i = 0; i < lines.length; i++) {
|
|
40
47
|
const line = lines[i].trim();
|
|
41
48
|
const funcMatch = /^func\s+(\(.*?\)\s+)?([a-zA-Z0-9_]+)(\(.*?\))/.exec(line);
|
|
49
|
+
|
|
42
50
|
if (funcMatch) {
|
|
43
51
|
items.push({
|
|
44
52
|
kind: "function",
|
|
@@ -48,7 +56,9 @@ function scanGo(lines) {
|
|
|
48
56
|
});
|
|
49
57
|
continue;
|
|
50
58
|
}
|
|
59
|
+
|
|
51
60
|
const typeMatch = /^type\s+([a-zA-Z0-9_]+)\s+(struct|interface)/.exec(line);
|
|
61
|
+
|
|
52
62
|
if (typeMatch) {
|
|
53
63
|
items.push({
|
|
54
64
|
kind: typeMatch[2],
|
|
@@ -58,6 +68,7 @@ function scanGo(lines) {
|
|
|
58
68
|
});
|
|
59
69
|
}
|
|
60
70
|
}
|
|
71
|
+
|
|
61
72
|
return items;
|
|
62
73
|
}
|
|
63
74
|
|
|
@@ -66,37 +77,50 @@ const JS_DECL_PATTERNS = [
|
|
|
66
77
|
[/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
|
|
67
78
|
[/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
|
|
68
79
|
];
|
|
80
|
+
|
|
69
81
|
// Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
|
|
70
82
|
const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
|
|
83
|
+
|
|
71
84
|
// Indented methods (object-literal adapters, class members) that open a block on the same line.
|
|
72
85
|
const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
|
|
86
|
+
|
|
73
87
|
const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
|
|
88
|
+
|
|
74
89
|
const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
|
|
75
90
|
|
|
76
91
|
function methodItem(line, lineNumber, depth) {
|
|
77
92
|
const match = JS_METHOD.exec(line) || JS_ARROW_PROPERTY.exec(line);
|
|
93
|
+
|
|
78
94
|
if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
|
|
95
|
+
|
|
79
96
|
return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
function declarationItem(line, rawLine, lineNumber) {
|
|
83
100
|
const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
|
|
101
|
+
|
|
84
102
|
for (const [pattern, isExport] of patterns) {
|
|
85
103
|
const match = pattern.exec(line);
|
|
104
|
+
|
|
86
105
|
if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
|
|
87
106
|
}
|
|
107
|
+
|
|
88
108
|
return null;
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
function scanJavaScript(lines) {
|
|
92
112
|
const items = [];
|
|
113
|
+
|
|
93
114
|
for (let i = 0; i < lines.length; i++) {
|
|
94
115
|
const line = lines[i].trim();
|
|
116
|
+
|
|
95
117
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
96
118
|
const indent = lines[i].length - lines[i].trimStart().length;
|
|
97
119
|
const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
|
|
120
|
+
|
|
98
121
|
if (item) items.push(item);
|
|
99
122
|
}
|
|
123
|
+
|
|
100
124
|
return items;
|
|
101
125
|
}
|
|
102
126
|
|
|
@@ -118,5 +142,6 @@ export function extractStructuralSurface(code, extension = "js") {
|
|
|
118
142
|
const ext = extension.replace(/^\./, "").toLowerCase();
|
|
119
143
|
const scanner = SCANNERS[ext] || SCANNERS.js;
|
|
120
144
|
const items = scanner(lines);
|
|
145
|
+
|
|
121
146
|
return { items, lineCount: lines.length };
|
|
122
147
|
}
|