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