pi-supernova 0.4.0 → 0.6.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 +86 -24
- package/docs/CHANGELOG.md +70 -0
- package/docs/TOKEN_COSTS.md +64 -28
- package/index.js +64 -25
- package/package.json +2 -2
- package/src/bridge/catalog.js +14 -10
- package/src/bridge/host-bridge.js +919 -158
- package/src/bridge/native-tools.js +16 -6
- package/src/context/evidence.js +13 -5
- package/src/context/fuzzy.js +34 -13
- package/src/context/outline.js +11 -2
- package/src/context/repo-index.js +190 -19
- package/src/context/search.js +70 -17
- package/src/context/snap.js +128 -40
- package/src/context/spans.js +39 -0
- package/src/context/surface.js +23 -15
- package/src/fs/check.js +10 -2
- package/src/fs/json-read.js +5 -1
- package/src/fs/patch.js +4 -2
- package/src/fs/vfs.js +133 -33
- package/src/fs/workspace.js +34 -4
- package/src/output/bottleneck.js +53 -15
- package/src/output/format.js +63 -29
- package/src/runtime/guest-worker.js +172 -61
- package/src/runtime/parallel.js +4 -1
- package/src/runtime/program-batch.js +17 -6
- package/src/runtime/program-file.js +6 -3
- package/src/runtime/reference.js +15 -22
- package/src/runtime/runtime.js +17 -8
- package/src/shared/decode.js +26 -3
- package/src/ui/omp-frame.js +11 -4
- package/src/ui/render.js +2 -2
package/src/context/search.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
import { isString } from "../shared/decode.js";
|
|
3
4
|
import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
@@ -12,6 +13,22 @@ function textResult(text, details) {
|
|
|
12
13
|
return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
async function candidateFileList(index, root, includeHidden = false, signal) {
|
|
17
|
+
const stat = await fs.stat(root).catch(() => null);
|
|
18
|
+
|
|
19
|
+
if (stat?.isFile()) return [root];
|
|
20
|
+
|
|
21
|
+
return index.files(root, includeHidden, signal);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pendingInScope(root, pendingPaths) {
|
|
25
|
+
return pendingPaths.filter(file => {
|
|
26
|
+
const relative = path.relative(root, file);
|
|
27
|
+
|
|
28
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
/** One bounded direct search for all changed names; no repository index or per-name spawn. */
|
|
16
33
|
export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
|
|
17
34
|
const references = new Map(names.map(name => [name, []]));
|
|
@@ -48,10 +65,10 @@ export async function referencesForNames({ root, names, excludePath, overlayText
|
|
|
48
65
|
if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
for (const file of pendingPaths) {
|
|
68
|
+
for (const file of pendingInScope(root, pendingPaths)) {
|
|
52
69
|
const text = overlayText(file);
|
|
53
70
|
|
|
54
|
-
if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
|
|
71
|
+
if (text !== undefined && Buffer.byteLength(text, "utf8") <= 512 * 1024) text.split("\n").forEach((line, i) => add(file, i + 1, line));
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
return { references, incomplete: result.outputTruncated === true };
|
|
@@ -59,29 +76,57 @@ export async function referencesForNames({ root, names, excludePath, overlayText
|
|
|
59
76
|
|
|
60
77
|
export function rgGrepArgs(pattern, params, searchPath) {
|
|
61
78
|
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
79
|
+
const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
|
|
62
80
|
|
|
63
|
-
if (
|
|
81
|
+
if (!caseSensitive) args.push("--ignore-case");
|
|
64
82
|
|
|
65
83
|
if (params?.glob) args.push("--glob", String(params.glob));
|
|
84
|
+
if (Number.isInteger(params?.limit) && params.limit > 0) args.push("--max-count", String(Math.min(params.limit, 2000)));
|
|
66
85
|
args.push("--", pattern, searchPath);
|
|
67
86
|
|
|
68
87
|
return args;
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
/** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
|
|
72
|
-
export async function listWithTools(searchDir, pattern, cwd, signal) {
|
|
91
|
+
export async function listWithTools(searchDir, pattern, cwd, signal, pendingPaths = []) {
|
|
92
|
+
const stat = await fs.stat(searchDir).catch(() => null);
|
|
93
|
+
const pendingAbs = pendingInScope(searchDir, pendingPaths);
|
|
94
|
+
const pending = pendingAbs.map(file => relativeSlash(cwd, file));
|
|
95
|
+
|
|
96
|
+
let matcher = null;
|
|
97
|
+
|
|
98
|
+
if (pattern) {
|
|
99
|
+
try { matcher = globToRegExp(pattern); }
|
|
100
|
+
catch { matcher = /^$/; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (stat?.isFile() || (!stat?.isDirectory() && pending.length)) {
|
|
104
|
+
const rel = stat?.isFile() ? relativeSlash(cwd, searchDir) : null;
|
|
105
|
+
const rows = [...new Set([...(rel ? [rel] : []), ...pending])].filter(file => !matcher || matcher.test(file));
|
|
106
|
+
|
|
107
|
+
return textResult(rows.length ? rows.join("\n") + "\n" : "", { via: pending.length ? "vfs" : "file" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pendingMerged = pendingAbs.filter((_, i) => !matcher || matcher.test(pending[i]));
|
|
111
|
+
const mergePending = stdout => {
|
|
112
|
+
const diskRows = String(stdout || "").split("\n").filter(Boolean)
|
|
113
|
+
.map(row => relativeSlash(cwd, path.isAbsolute(row) ? row : path.resolve(cwd, row)));
|
|
114
|
+
const rows = [...new Set([...diskRows, ...pendingMerged])];
|
|
115
|
+
|
|
116
|
+
return rows.length ? rows.join("\n") + "\n" : "";
|
|
117
|
+
};
|
|
73
118
|
const args = ["--files"];
|
|
74
119
|
|
|
75
120
|
if (pattern) args.push("-g", pattern);
|
|
76
121
|
const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
|
|
77
122
|
|
|
78
|
-
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
|
|
123
|
+
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(mergePending(res.stdout), { via: "rg", outputTruncated: res.outputTruncated === true });
|
|
79
124
|
const findArgs = [searchDir];
|
|
80
125
|
|
|
81
126
|
if (pattern) findArgs.push("-name", pattern);
|
|
82
127
|
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
83
128
|
|
|
84
|
-
return textResult(findRes.stdout, { via: "find" });
|
|
129
|
+
return textResult(mergePending(findRes.stdout), { via: "find", outputTruncated: findRes.outputTruncated === true });
|
|
85
130
|
}
|
|
86
131
|
|
|
87
132
|
const GLOB_CHARS = /[*?[\]{}]/;
|
|
@@ -90,9 +135,9 @@ const GLOB_CHARS = /[*?[\]{}]/;
|
|
|
90
135
|
* fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
|
|
91
136
|
* Returns "path" rows (best first) or null when the pattern is a real glob.
|
|
92
137
|
*/
|
|
93
|
-
export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
138
|
+
export async function fuzzyFind(index, root, cwd, pattern, limit = 20, pendingPaths = []) {
|
|
94
139
|
if (!pattern || GLOB_CHARS.test(pattern)) return null;
|
|
95
|
-
const files = await index
|
|
140
|
+
const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
|
|
96
141
|
|
|
97
142
|
if (!index.canScan(files)) return null;
|
|
98
143
|
const rel = files.map((f) => relativeSlash(cwd, f));
|
|
@@ -109,12 +154,12 @@ export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
|
109
154
|
}
|
|
110
155
|
|
|
111
156
|
/** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
|
|
112
|
-
export async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
157
|
+
export async function grepIndexed(index, pattern, params, searchPath, cwd, overlayText = () => undefined, pendingPaths = []) {
|
|
113
158
|
const compiled = grepRegex(pattern, params);
|
|
114
159
|
|
|
115
160
|
if (!compiled) return null;
|
|
116
161
|
const { regex, caseSensitive } = compiled;
|
|
117
|
-
let files = await index
|
|
162
|
+
let files = [...new Set([...await candidateFileList(index, searchPath), ...pendingInScope(searchPath, pendingPaths)])];
|
|
118
163
|
|
|
119
164
|
if (!index.canScan(files)) return null;
|
|
120
165
|
|
|
@@ -123,14 +168,19 @@ export async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
|
123
168
|
files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
|
|
124
169
|
}
|
|
125
170
|
|
|
126
|
-
const
|
|
127
|
-
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
const overlay = overlayText(file);
|
|
173
|
+
|
|
174
|
+
if (overlay === undefined && index.entry(file) === null) return null;
|
|
175
|
+
}
|
|
176
|
+
const rows = index.grepRows(files, regex, cwd, overlayText);
|
|
177
|
+
const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText) : rows;
|
|
128
178
|
|
|
129
179
|
return formatGrepRows(fallback, grepLimit(params));
|
|
130
180
|
}
|
|
131
181
|
|
|
132
182
|
function grepLimit(params) {
|
|
133
|
-
return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
|
|
183
|
+
return Number.isInteger(params?.limit) && params.limit > 0 ? Math.min(params.limit, 2000) : 200;
|
|
134
184
|
}
|
|
135
185
|
|
|
136
186
|
function grepRegex(pattern, params) {
|
|
@@ -144,12 +194,15 @@ function grepRegex(pattern, params) {
|
|
|
144
194
|
}
|
|
145
195
|
|
|
146
196
|
/** Zero literal hits: retry each line fuzzily (1 typo, 2 for long names) within a tight span, so IsOffTheRecord finds is_off_the_record. */
|
|
147
|
-
function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
|
|
197
|
+
function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText = () => undefined) {
|
|
148
198
|
const maxTypos = pattern.length >= 8 ? 2 : 1;
|
|
149
199
|
const rows = [];
|
|
150
200
|
|
|
151
201
|
for (const filePath of files) {
|
|
152
|
-
const
|
|
202
|
+
const pending = overlayText(filePath);
|
|
203
|
+
const e = pending === undefined
|
|
204
|
+
? index.entry(filePath)
|
|
205
|
+
: Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
|
|
153
206
|
|
|
154
207
|
if (!e) continue;
|
|
155
208
|
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
@@ -197,8 +250,8 @@ function formatGrepRows(rows, limit) {
|
|
|
197
250
|
}
|
|
198
251
|
|
|
199
252
|
/** rg --files [-g pattern] served from the index; null when the tree is too large. */
|
|
200
|
-
export async function listIndexed(index, root, cwd, pattern) {
|
|
201
|
-
const files = await index
|
|
253
|
+
export async function listIndexed(index, root, cwd, pattern, pendingPaths = []) {
|
|
254
|
+
const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
|
|
202
255
|
|
|
203
256
|
if (!index.canScan(files)) return null;
|
|
204
257
|
const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
|
package/src/context/snap.js
CHANGED
|
@@ -3,6 +3,8 @@ import { isString } from "../shared/decode.js";
|
|
|
3
3
|
import { truncateChars } from "../output/format.js";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import { extractStructuralSurface } from "./surface.js";
|
|
6
|
+
import { WorkspaceIndex } from "./repo-index.js";
|
|
7
|
+
import { pickSpan, spanCandidate, spanWindow } from "./spans.js";
|
|
6
8
|
import { rankPaths } from "./fuzzy.js";
|
|
7
9
|
import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
|
|
8
10
|
|
|
@@ -18,6 +20,8 @@ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
|
|
|
18
20
|
|
|
19
21
|
const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
|
|
20
22
|
|
|
23
|
+
const MAX_NEEDLE_CHARS = 128;
|
|
24
|
+
|
|
21
25
|
const MAX_ALTERNATIVES = 3;
|
|
22
26
|
|
|
23
27
|
/** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
|
|
@@ -80,21 +84,25 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
|
80
84
|
const lower = relative.toLowerCase();
|
|
81
85
|
const base = path.basename(lower);
|
|
82
86
|
|
|
87
|
+
const extension = path.extname(base);
|
|
88
|
+
const stemBase = extension ? base.slice(0, -extension.length) : base;
|
|
83
89
|
const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
|
|
84
|
-
||
|
|
90
|
+
|| stemBase === query.toLowerCase();
|
|
91
|
+
|
|
92
|
+
const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
|
|
85
93
|
|
|
86
94
|
return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
|
|
87
|
-
pathCoverage: tokens.filter(token => lower.includes(token)).length,
|
|
95
|
+
pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
|
|
88
96
|
matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
|
|
89
|
-
line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
|
|
97
|
+
line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
100
|
+
function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
|
|
93
101
|
const text = raw.replace(/\r?\n$/, "");
|
|
94
102
|
const lower = text.toLowerCase();
|
|
95
103
|
|
|
96
104
|
if (isMatch) {
|
|
97
|
-
const matches = tokens.filter(token => lower.includes(token));
|
|
105
|
+
const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
|
|
98
106
|
|
|
99
107
|
for (const token of matches) candidate.matched.add(token);
|
|
100
108
|
const ext = path.extname(candidate.path).toLowerCase();
|
|
@@ -106,7 +114,7 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
|
106
114
|
for (const item of items) {
|
|
107
115
|
const name = item.name.toLowerCase();
|
|
108
116
|
const itemExact = name === query.toLowerCase();
|
|
109
|
-
const coverage = tokens.filter(token => name.includes(token)).length;
|
|
117
|
+
const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
|
|
110
118
|
|
|
111
119
|
if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
|
|
112
120
|
|
|
@@ -115,6 +123,8 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
|
115
123
|
|
|
116
124
|
const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
|
|
117
125
|
|
|
126
|
+
if (exact) candidate.exactLines.add(lineNumber);
|
|
127
|
+
|
|
118
128
|
if (score > candidate.anchorScore) {
|
|
119
129
|
candidate.anchorScore = score;
|
|
120
130
|
candidate.line = lineNumber;
|
|
@@ -136,33 +146,47 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
|
136
146
|
if (candidate.recent.length > 2) candidate.recent.shift();
|
|
137
147
|
}
|
|
138
148
|
|
|
139
|
-
function inspectOverlay(candidate, text, needles, query, tokens) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
function inspectOverlay(candidate, text, needles, query, tokens, signal) {
|
|
150
|
+
let start = 0, line = 1, truncated = false;
|
|
151
|
+
|
|
152
|
+
// Keep only the candidate and its short context, not another copy of every
|
|
153
|
+
// line in a staged document. Oversized individual lines disclose uncertainty.
|
|
154
|
+
while (start < text.length) {
|
|
155
|
+
if ((line & 127) === 0) signal?.throwIfAborted();
|
|
156
|
+
const newline = text.indexOf("\n", start);
|
|
157
|
+
const end = newline < 0 ? text.length : newline + 1;
|
|
158
|
+
|
|
159
|
+
if (end - start > MAX_SEARCH_CHARS) truncated = true;
|
|
160
|
+
else {
|
|
161
|
+
const row = text.slice(start, end);
|
|
162
|
+
const lower = row.toLowerCase();
|
|
163
|
+
inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
|
|
164
|
+
}
|
|
165
|
+
start = end;
|
|
166
|
+
line++;
|
|
167
|
+
}
|
|
147
168
|
|
|
148
|
-
|
|
169
|
+
return truncated;
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
|
|
152
|
-
const needles = exact ? [query.toLowerCase()] : tokens;
|
|
172
|
+
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
|
|
173
|
+
const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
|
|
174
|
+
const searchNeedles = [...new Set(needles)];
|
|
175
|
+
const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
|
|
176
|
+
const candidates = new Map();
|
|
177
|
+
|
|
153
178
|
const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
|
|
154
179
|
|
|
155
180
|
if (includeHidden) args.push("--hidden");
|
|
156
181
|
args.push("-g", "!.git/**", "-g", "!**/.git/**");
|
|
157
182
|
|
|
158
|
-
for (const needle of
|
|
159
|
-
args.push("--", dir);
|
|
183
|
+
for (const needle of searchNeedles) args.push("-e", needle);
|
|
184
|
+
args.push("--", focusFile ?? dir);
|
|
160
185
|
|
|
161
|
-
const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
|
|
186
|
+
const response = diskFiles || (focusFile && overlayText(focusFile) === undefined) ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
|
|
162
187
|
: { stdout: "", stderr: "", exitCode: 1 };
|
|
163
188
|
|
|
164
189
|
if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
|
|
165
|
-
const candidates = new Map();
|
|
166
190
|
const records = response.stdout.split("\n");
|
|
167
191
|
|
|
168
192
|
for (let i = 0; i < records.length; i++) {
|
|
@@ -186,24 +210,26 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pen
|
|
|
186
210
|
let candidate = candidates.get(filePath);
|
|
187
211
|
|
|
188
212
|
if (!candidate) {
|
|
189
|
-
candidate = makeCandidate(filePath,
|
|
213
|
+
candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
190
214
|
candidates.set(filePath, candidate);
|
|
191
215
|
}
|
|
192
216
|
|
|
193
|
-
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
|
|
217
|
+
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
|
|
194
218
|
}
|
|
195
219
|
|
|
220
|
+
let overlayTruncated = false;
|
|
221
|
+
|
|
196
222
|
for (const filePath of pendingPaths) {
|
|
197
223
|
const pending = overlayText(filePath);
|
|
198
224
|
|
|
199
225
|
if (pending === undefined) continue;
|
|
200
|
-
const candidate = makeCandidate(filePath,
|
|
201
|
-
inspectOverlay(candidate, pending, needles, query, tokens);
|
|
226
|
+
const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
227
|
+
overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
|
|
202
228
|
|
|
203
229
|
if (candidate.matched.size) candidates.set(filePath, candidate);
|
|
204
230
|
}
|
|
205
231
|
|
|
206
|
-
return { candidates, truncated: response.outputTruncated === true };
|
|
232
|
+
return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
|
|
207
233
|
}
|
|
208
234
|
|
|
209
235
|
function rankScore(candidate, tokenCount) {
|
|
@@ -220,13 +246,61 @@ function location(candidate, root) {
|
|
|
220
246
|
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
221
247
|
}
|
|
222
248
|
|
|
249
|
+
async function spanCandidates(filePath, lines, root, overlayText, signal) {
|
|
250
|
+
const staged = overlayText(filePath);
|
|
251
|
+
const rel = path.relative(root, filePath);
|
|
252
|
+
let text = staged;
|
|
253
|
+
|
|
254
|
+
if (text === undefined) {
|
|
255
|
+
const file = await fs.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const stat = await file.stat();
|
|
259
|
+
|
|
260
|
+
if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
|
|
261
|
+
if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
|
|
262
|
+
text = await file.readFile({ encoding: "utf8", signal });
|
|
263
|
+
} finally { await file.close(); }
|
|
264
|
+
}
|
|
265
|
+
const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
|
|
266
|
+
|
|
267
|
+
return lines.map(line => {
|
|
268
|
+
const span = pickSpan(spans, { line }) ?? { start: line, end: line };
|
|
269
|
+
const end = Math.min(span.end, span.start + 119);
|
|
270
|
+
|
|
271
|
+
return spanCandidate(rel, line, spanWindow(text, span.start, end));
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function rankedSpanCandidates(ranked, root, overlayText, signal) {
|
|
276
|
+
const out = [];
|
|
277
|
+
|
|
278
|
+
for (const candidate of ranked) {
|
|
279
|
+
const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
|
|
280
|
+
const staged = overlayText(candidate.path);
|
|
281
|
+
let large = false;
|
|
282
|
+
|
|
283
|
+
if (staged !== undefined) large = Buffer.byteLength(staged) > 512 * 1024;
|
|
284
|
+
else try { large = (await fs.stat(candidate.path)).size > 512 * 1024; } catch {}
|
|
285
|
+
|
|
286
|
+
if (large) out.push(location(candidate, root));
|
|
287
|
+
else {
|
|
288
|
+
try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
|
|
289
|
+
catch (error) { signal?.throwIfAborted(); out.push(location(candidate, root)); }
|
|
290
|
+
}
|
|
291
|
+
if (out.length >= MAX_ALTERNATIVES) break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return out.slice(0, MAX_ALTERNATIVES);
|
|
295
|
+
}
|
|
296
|
+
|
|
223
297
|
export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
|
|
224
298
|
const flags = tokenizeQuery(query);
|
|
299
|
+
|
|
300
|
+
if (flags.tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
|
|
225
301
|
const tokens = [...new Set(flags.tokens.map(stem))];
|
|
226
302
|
|
|
227
303
|
if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
|
|
228
|
-
|
|
229
|
-
if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
|
|
230
304
|
query = query.trim();
|
|
231
305
|
const dir = path.resolve(searchDir || process.cwd());
|
|
232
306
|
|
|
@@ -235,15 +309,21 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
235
309
|
flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
|
|
236
310
|
pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
|
|
237
311
|
|
|
238
|
-
const
|
|
239
|
-
|
|
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;
|
|
240
315
|
|
|
241
|
-
return
|
|
316
|
+
return null;
|
|
242
317
|
});
|
|
243
318
|
|
|
244
|
-
|
|
319
|
+
if (!dirStat && !pendingPaths.length) return { ...empty, status: "not_found" };
|
|
320
|
+
const diskFiles = dirStat?.isDirectory() === true;
|
|
321
|
+
const focusFile = dirStat?.isFile() === true || pendingPaths.includes(dir) ? dir : null;
|
|
322
|
+
|
|
245
323
|
const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
|
|
246
|
-
|
|
324
|
+
|
|
325
|
+
if (!tokens.length) return { ...empty, status: "not_found" };
|
|
326
|
+
const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile });
|
|
247
327
|
// A declaration hit needs no prerequisite file listing or persistent index.
|
|
248
328
|
// Bare names can name files, even when callers mention the same word.
|
|
249
329
|
const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
|
|
@@ -254,26 +334,27 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
254
334
|
|
|
255
335
|
if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
|
|
256
336
|
|
|
257
|
-
const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...pendingPaths])]
|
|
337
|
+
const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...(focusFile ? [focusFile] : []), ...pendingPaths])]
|
|
258
338
|
.filter(file => inScope(file, dir, includeHidden));
|
|
339
|
+
const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
|
|
259
340
|
|
|
260
341
|
for (const filePath of paths) {
|
|
261
342
|
if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
|
|
262
|
-
const relative = path.relative(
|
|
343
|
+
const relative = path.relative(candidateRoot, filePath).toLowerCase();
|
|
263
344
|
|
|
264
345
|
if (!tokens.some(token => relative.includes(token))) continue;
|
|
265
346
|
|
|
266
347
|
if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
|
|
267
|
-
const candidate = makeCandidate(filePath,
|
|
348
|
+
const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
268
349
|
|
|
269
|
-
if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
|
|
350
|
+
if (focusFile || candidate.pathScore > 0) search.candidates.set(filePath, candidate);
|
|
270
351
|
}
|
|
271
352
|
|
|
272
353
|
const ranked = [];
|
|
273
354
|
|
|
274
355
|
for (const candidate of search.candidates.values()) {
|
|
275
|
-
if (candidate.pathScore > -50) {
|
|
276
|
-
ranked.push({ ...candidate, score: rankScore(candidate, tokens.length) });
|
|
356
|
+
if (focusFile || candidate.pathScore > -50) {
|
|
357
|
+
ranked.push({ ...candidate, score: focusFile ? Math.max(1, rankScore(candidate, tokens.length)) : rankScore(candidate, tokens.length) });
|
|
277
358
|
}
|
|
278
359
|
}
|
|
279
360
|
|
|
@@ -306,7 +387,14 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
306
387
|
const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
|
|
307
388
|
const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
|
|
308
389
|
|
|
309
|
-
if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5))
|
|
390
|
+
if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
|
|
391
|
+
return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText, signal) };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (best.exactLines.size > 1) {
|
|
395
|
+
return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText, signal) };
|
|
396
|
+
}
|
|
397
|
+
|
|
310
398
|
const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
|
|
311
399
|
|
|
312
400
|
return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { truncateChars } from "../output/format.js";
|
|
2
|
+
|
|
3
|
+
export function pickSpan(spans, { line, name } = {}) {
|
|
4
|
+
const needle = typeof name === "string" && /^[A-Za-z_$][\w$]*$/.test(name.trim()) ? name.trim().toLowerCase() : "";
|
|
5
|
+
const named = needle ? spans.filter(item => item.name.toLowerCase() === needle) : [];
|
|
6
|
+
|
|
7
|
+
if (named.length === 1) return named[0];
|
|
8
|
+
if (!line) return;
|
|
9
|
+
|
|
10
|
+
return spans.find(item => item.start === line)
|
|
11
|
+
?? spans.filter(item => item.start <= line && line <= item.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function spanWindow(text, start, end) {
|
|
15
|
+
const raw = text.split("\n");
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
start,
|
|
19
|
+
end,
|
|
20
|
+
text: raw.slice(start - 1, end).join("\n"),
|
|
21
|
+
signature: truncateChars((raw[start - 1] ?? "").trim().replace(/\{.*$/, "").trim(), 240, "signature").text,
|
|
22
|
+
context: Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => {
|
|
23
|
+
const n = start + i;
|
|
24
|
+
|
|
25
|
+
return { line: n, text: raw[n - 1] ?? "" };
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function spanCandidate(relPath, line, window) {
|
|
31
|
+
return {
|
|
32
|
+
path: relPath,
|
|
33
|
+
line,
|
|
34
|
+
lines: [window.start, window.end],
|
|
35
|
+
text: window.text,
|
|
36
|
+
signature: window.signature,
|
|
37
|
+
context: window.context.map(row => (row.line === line ? "►" : " ") + row.line + " " + row.text),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/context/surface.js
CHANGED
|
@@ -8,14 +8,20 @@ function scanPython(lines) {
|
|
|
8
8
|
const line = lines[i];
|
|
9
9
|
const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
|
|
10
10
|
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
if (match) {
|
|
12
|
+
items.push({
|
|
13
|
+
kind: match[2].includes("def") ? "function" : "class",
|
|
14
|
+
name: match[3],
|
|
15
|
+
signature: match[0].trim(),
|
|
16
|
+
line: i + 1,
|
|
17
|
+
depth: Math.floor(match[1].length / 4),
|
|
18
|
+
});
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const constant = /^([ \t]*)([A-Z][A-Z0-9_]*)\s*(?::[^=\n]+)?=/.exec(line);
|
|
23
|
+
|
|
24
|
+
if (constant && constant[1].length === 0) items.push({ kind: "constant", name: constant[2], signature: constant[0].replace(/=\s*$/, "=").trim(), line: i + 1, depth: 0 });
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
return items;
|
|
@@ -81,10 +87,10 @@ const JS_DECL_PATTERNS = [
|
|
|
81
87
|
// Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
|
|
82
88
|
const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
|
|
83
89
|
|
|
84
|
-
// Indented methods (object-literal adapters, class members)
|
|
85
|
-
const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{
|
|
90
|
+
// Indented methods (object-literal adapters, class members), including one-liners.
|
|
91
|
+
const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{/;
|
|
86
92
|
|
|
87
|
-
const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{
|
|
93
|
+
const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{/;
|
|
88
94
|
|
|
89
95
|
const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
|
|
90
96
|
|
|
@@ -93,16 +99,18 @@ function methodItem(line, lineNumber, depth) {
|
|
|
93
99
|
|
|
94
100
|
if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
|
|
95
101
|
|
|
96
|
-
return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{
|
|
102
|
+
return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{.*$/, "").trim(), line: lineNumber, depth };
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
function declarationItem(line, rawLine, lineNumber) {
|
|
106
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
107
|
+
const depth = Math.floor(indent / 2);
|
|
100
108
|
const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
|
|
101
109
|
|
|
102
110
|
for (const [pattern, isExport] of patterns) {
|
|
103
111
|
const match = pattern.exec(line);
|
|
104
112
|
|
|
105
|
-
if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth
|
|
113
|
+
if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth };
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
return null;
|
|
@@ -116,7 +124,7 @@ function scanJavaScript(lines) {
|
|
|
116
124
|
|
|
117
125
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
118
126
|
const indent = lines[i].length - lines[i].trimStart().length;
|
|
119
|
-
const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
|
|
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);
|
|
120
128
|
|
|
121
129
|
if (item) items.push(item);
|
|
122
130
|
}
|
|
@@ -139,7 +147,7 @@ const SCANNERS = {
|
|
|
139
147
|
export function extractStructuralSurface(code, extension = "js") {
|
|
140
148
|
if (!isString(code) || !code.trim()) return { items: [], lineCount: 0 };
|
|
141
149
|
const lines = code.split("\n");
|
|
142
|
-
const ext = extension.replace(/^\./, "").toLowerCase();
|
|
150
|
+
const ext = String(extension ?? "").replace(/^\./, "").toLowerCase();
|
|
143
151
|
const scanner = SCANNERS[ext] || SCANNERS.js;
|
|
144
152
|
const items = scanner(lines);
|
|
145
153
|
|
package/src/fs/check.js
CHANGED
|
@@ -60,7 +60,7 @@ function skipComment(text, i) {
|
|
|
60
60
|
|
|
61
61
|
const end = text.indexOf("*/", i + 2);
|
|
62
62
|
|
|
63
|
-
return end < 0 ?
|
|
63
|
+
return end < 0 ? -1 : end + 2;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function skipRegex(text, i) {
|
|
@@ -126,7 +126,13 @@ function consumeLiteral(text, i, stack, prev) {
|
|
|
126
126
|
|
|
127
127
|
if (c !== "/") return null;
|
|
128
128
|
|
|
129
|
-
if (text[i + 1] === "/" || text[i + 1] === "*")
|
|
129
|
+
if (text[i + 1] === "/" || text[i + 1] === "*") {
|
|
130
|
+
const end = skipComment(text, i);
|
|
131
|
+
|
|
132
|
+
if (end < 0) return { error: "unterminated comment", at: i };
|
|
133
|
+
|
|
134
|
+
return { end, prev };
|
|
135
|
+
}
|
|
130
136
|
|
|
131
137
|
if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
|
|
132
138
|
const end = skipRegex(text, i);
|
|
@@ -190,6 +196,8 @@ const CODE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts",
|
|
|
190
196
|
|
|
191
197
|
/** { ok: true } | { ok: false, message }; message names the problem and line. */
|
|
192
198
|
export function quickCheck(text, ext) {
|
|
199
|
+
ext = String(ext ?? "").toLowerCase();
|
|
200
|
+
|
|
193
201
|
if (ext === ".json") {
|
|
194
202
|
try {
|
|
195
203
|
JSON.parse(text);
|
package/src/fs/json-read.js
CHANGED
|
@@ -43,7 +43,11 @@ export function jsonProjector(json) {
|
|
|
43
43
|
return function* (root) {
|
|
44
44
|
for (const steps of plans) yield steps.reduce((value, step) => {
|
|
45
45
|
if (step.key !== undefined) {
|
|
46
|
-
if (!isObject(value) || !Object.hasOwn(value, step.key))
|
|
46
|
+
if (!isObject(value) || !Object.hasOwn(value, step.key)) {
|
|
47
|
+
const keys = isObject(value) ? Object.keys(value) : [];
|
|
48
|
+
const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
|
|
49
|
+
throw new Error("JSON field not found: " + JSON.stringify(step.key) + preview);
|
|
50
|
+
}
|
|
47
51
|
|
|
48
52
|
return value[step.key];
|
|
49
53
|
}
|