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