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