pi-supernova 0.5.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 +73 -11
- package/docs/CHANGELOG.md +46 -0
- package/docs/TOKEN_COSTS.md +63 -29
- package/index.js +7 -4
- package/package.json +2 -2
- package/src/bridge/catalog.js +14 -10
- package/src/bridge/host-bridge.js +810 -145
- 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 +150 -15
- package/src/context/search.js +70 -17
- package/src/context/snap.js +98 -46
- package/src/context/surface.js +15 -9
- package/src/fs/check.js +10 -2
- package/src/fs/patch.js +4 -2
- package/src/fs/vfs.js +133 -36
- package/src/fs/workspace.js +7 -1
- package/src/output/bottleneck.js +36 -11
- package/src/output/format.js +44 -29
- package/src/runtime/guest-worker.js +135 -39
- 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 +5 -5
- package/src/runtime/runtime.js +17 -6
- package/src/shared/decode.js +15 -3
- package/src/ui/omp-frame.js +11 -4
- package/src/ui/render.js +2 -2
|
@@ -54,7 +54,11 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
function settingsFor(ctx) {
|
|
57
|
-
|
|
57
|
+
try {
|
|
58
|
+
return host.SettingsManager?.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted?.() === true });
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
async function readOne(args, signal, ctx, bridge, id) {
|
|
@@ -67,7 +71,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
|
67
71
|
signal?.throwIfAborted();
|
|
68
72
|
const image = /\.(png|jpe?g|gif|webp|bmp)$/i.test(target);
|
|
69
73
|
|
|
70
|
-
if (stat?.isFile() &&
|
|
74
|
+
if (stat?.isFile() && args.about === undefined && args.query === undefined && args.resolve === undefined && args.outline === undefined && args.evidence === undefined && args.json === undefined && args.complete === undefined) {
|
|
71
75
|
// Pi retains image handling, full text, line windows, truncation metadata,
|
|
72
76
|
// and actionable continuation offsets. Do not summarize or dedupe these.
|
|
73
77
|
return factories.read(ctx.cwd, { autoResizeImages: image ? settingsFor(ctx)?.getImageAutoResize() : undefined }).execute(id, { ...args, path: target }, signal, undefined, ctx);
|
|
@@ -77,7 +81,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
async function execute(name, id, args, signal, onUpdate, context) {
|
|
80
|
-
const ctx = { ...context, cwd: context?.cwd
|
|
84
|
+
const ctx = { ...context, cwd: isString(context?.cwd) && context.cwd ? context.cwd : cwd };
|
|
81
85
|
const bridge = base.fork({ getCwd: () => ctx.cwd });
|
|
82
86
|
bridge.bindCallContext(ctx, signal);
|
|
83
87
|
signal?.throwIfAborted();
|
|
@@ -166,11 +170,17 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
|
166
170
|
};
|
|
167
171
|
|
|
168
172
|
if (name === "read") {
|
|
169
|
-
tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question. An array of paths returns all readable files and labels individual errors.";
|
|
173
|
+
tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question (at most 16 keywords). An array of up to 64 paths returns all readable files and labels individual errors. Images return as attachments up to 20 MiB each; at most 16 image attachments are returned.";
|
|
170
174
|
tool.parameters = { ...definition.parameters, properties: {
|
|
171
175
|
...definition.parameters.properties,
|
|
172
176
|
path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }], description: "File, directory, source question, or up to 64 paths to read" },
|
|
173
|
-
about: { type: "string", description: "Focus on this question or symbol; source selection reports uncertainty instead of guessing" },
|
|
177
|
+
about: { type: "string", description: "Focus on this question or symbol (at most 16 keywords); source selection reports uncertainty instead of guessing" },
|
|
178
|
+
query: { type: "string", description: "Source question; optional path scopes the search" },
|
|
179
|
+
outline: { type: "boolean" },
|
|
180
|
+
evidence: { type: "boolean" },
|
|
181
|
+
resolve: { type: "boolean" },
|
|
182
|
+
complete: { type: "boolean" },
|
|
183
|
+
json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }] },
|
|
174
184
|
} };
|
|
175
185
|
tool.promptGuidelines = [...(definition.promptGuidelines || []), "read can find source from a symbol or question; check its selection status before choosing a file. Plain file reads preserve full text within the stated limits."];
|
|
176
186
|
}
|
|
@@ -179,7 +189,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
|
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
pi.on("session_start", (_event, ctx) => {
|
|
182
|
-
cwd = ctx?.cwd
|
|
192
|
+
cwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
|
|
183
193
|
base.invalidateFiles();
|
|
184
194
|
});
|
|
185
195
|
pi.registerCommand("supernova", {
|
package/src/context/evidence.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 { WorkspaceIndex } from "./repo-index.js";
|
|
3
4
|
import { tokenizeQuery, scorePathTopology, stem } from "./snap.js";
|
|
@@ -143,7 +144,7 @@ function lexicalSim(a, b) {
|
|
|
143
144
|
|
|
144
145
|
function activateEntities(profile, graph) {
|
|
145
146
|
const eta = new Map();
|
|
146
|
-
const anchors = profile.subjects.length ? profile.subjects : profile.
|
|
147
|
+
const anchors = profile.subjects.length ? profile.subjects : profile.stems;
|
|
147
148
|
|
|
148
149
|
for (const anchor of anchors) {
|
|
149
150
|
let best = null;
|
|
@@ -388,12 +389,12 @@ function candidateFiles(files, profile, index, limit, overlayText) {
|
|
|
388
389
|
|
|
389
390
|
scored.sort((a, b) => b.s - a.s);
|
|
390
391
|
const chosen = new Set();
|
|
391
|
-
const anchors = (profile.subjects.length ? profile.subjects : profile.
|
|
392
|
+
const anchors = (profile.subjects.length ? profile.subjects : profile.stems).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
392
393
|
|
|
393
394
|
const pendingHits = files.filter(file => {
|
|
394
395
|
const pending = overlayText(file);
|
|
395
396
|
|
|
396
|
-
return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
|
|
397
|
+
return pending !== undefined && Buffer.byteLength(pending, "utf8") <= 512 * 1024 && anchors.some(anchor => pending.toLowerCase().includes(anchor));
|
|
397
398
|
});
|
|
398
399
|
|
|
399
400
|
const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
|
|
@@ -511,7 +512,12 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
|
|
|
511
512
|
return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
|
|
512
513
|
});
|
|
513
514
|
|
|
514
|
-
const
|
|
515
|
+
const rootStat = await fs.stat(searchRoot).catch(error => {
|
|
516
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
|
|
517
|
+
throw error;
|
|
518
|
+
});
|
|
519
|
+
const diskFiles = rootStat?.isFile() ? [searchRoot] : rootStat ? await index.files(searchRoot) : [];
|
|
520
|
+
const files = [...new Set([...diskFiles, ...staged])];
|
|
515
521
|
|
|
516
522
|
if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
|
|
517
523
|
|
|
@@ -520,7 +526,9 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
|
|
|
520
526
|
|
|
521
527
|
for (const f of chosenFiles) {
|
|
522
528
|
const pending = overlayText(f);
|
|
523
|
-
const entry = pending === undefined
|
|
529
|
+
const entry = pending === undefined
|
|
530
|
+
? index.entry(f)
|
|
531
|
+
: Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(f, pending) : null;
|
|
524
532
|
|
|
525
533
|
if (!entry) continue;
|
|
526
534
|
spans.push(...spansOf(entry, f, opts.maxSpanLines));
|
package/src/context/fuzzy.js
CHANGED
|
@@ -15,6 +15,7 @@ const AI_DECAY = Math.LN2 / 3; // per day
|
|
|
15
15
|
const AI_MAX_HISTORY_DAYS = 7;
|
|
16
16
|
|
|
17
17
|
const MAX_TIMESTAMPS_PER_FILE = 128;
|
|
18
|
+
const MAX_FRECENCY_FILES = 10000;
|
|
18
19
|
|
|
19
20
|
const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
|
|
20
21
|
|
|
@@ -26,7 +27,10 @@ export class Frecency {
|
|
|
26
27
|
record(filePath, at = Date.now() / 1000) {
|
|
27
28
|
let list = this.access.get(filePath);
|
|
28
29
|
|
|
29
|
-
if (!list)
|
|
30
|
+
if (!list) {
|
|
31
|
+
if (this.access.size >= MAX_FRECENCY_FILES) this.access.delete(this.access.keys().next().value);
|
|
32
|
+
this.access.set(filePath, (list = []));
|
|
33
|
+
}
|
|
30
34
|
list.push(at);
|
|
31
35
|
|
|
32
36
|
if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
|
|
@@ -124,20 +128,35 @@ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false }
|
|
|
124
128
|
|
|
125
129
|
if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
|
|
126
130
|
|
|
127
|
-
if (maxTypos <= 0 || needle.length < 3) return null;
|
|
128
|
-
|
|
131
|
+
if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
|
|
132
|
+
const memo = new Map();
|
|
129
133
|
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
|
|
134
|
+
const visit = (part, typosLeft) => {
|
|
135
|
+
const key = part + "\0" + typosLeft;
|
|
133
136
|
|
|
134
|
-
if (
|
|
135
|
-
|
|
137
|
+
if (memo.has(key)) return memo.get(key);
|
|
138
|
+
let best = matchOnce(part, hay, caseSensitive);
|
|
136
139
|
|
|
137
|
-
if (
|
|
138
|
-
|
|
140
|
+
if (best) best = { ...best, typos: 0, exact: hay.toLowerCase() === part.toLowerCase() };
|
|
141
|
+
|
|
142
|
+
if (typosLeft > 0) {
|
|
143
|
+
for (let i = 0; i < part.length; i++) {
|
|
144
|
+
const shorter = part.slice(0, i) + part.slice(i + 1);
|
|
145
|
+
const m = visit(shorter, typosLeft - 1);
|
|
146
|
+
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
|
|
149
|
+
|
|
150
|
+
if (!best || scored.score > best.score) best = scored;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
memo.set(key, best);
|
|
139
155
|
|
|
140
|
-
|
|
156
|
+
return best;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return visit(needle, maxTypos);
|
|
141
160
|
}
|
|
142
161
|
|
|
143
162
|
export function smartCase(query) {
|
|
@@ -164,7 +183,7 @@ function distancePenalty(currentDir, candidateDir) {
|
|
|
164
183
|
export function rankPaths(query, paths, ctx = {}) {
|
|
165
184
|
const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
|
|
166
185
|
|
|
167
|
-
if (parts.length === 0) return [];
|
|
186
|
+
if (parts.length === 0 || parts.length > 16) return [];
|
|
168
187
|
const caseSensitive = smartCase(query);
|
|
169
188
|
const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
|
|
170
189
|
const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
|
|
@@ -212,7 +231,9 @@ function filenameBonus(base, rel, filenameStart, first, needle) {
|
|
|
212
231
|
|
|
213
232
|
/** fff: frecency boost base·f/100 and +15% for git-modified files. */
|
|
214
233
|
function contextBoost(base, rel, ctx) {
|
|
215
|
-
|
|
234
|
+
let frecency = 0;
|
|
235
|
+
|
|
236
|
+
try { frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0; } catch {}
|
|
216
237
|
const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
|
|
217
238
|
|
|
218
239
|
return Math.floor((base * frecency) / 100) + gitBoost;
|
package/src/context/outline.js
CHANGED
|
@@ -28,6 +28,7 @@ function relevance(span, lower, stems) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function chooseExpanded(spans, lower, stems, raw, opts) {
|
|
31
|
+
if (stems.length === 0) return new Set();
|
|
31
32
|
const scored = spans.map((s, i) => ({ i, r: relevance(s, lower, stems), chars: raw.slice(s.start - 1, s.end).join("\n").length }));
|
|
32
33
|
scored.sort((a, b) => b.r - a.r || a.i - b.i);
|
|
33
34
|
const expanded = new Set();
|
|
@@ -118,7 +119,15 @@ export function outlineFile(entry, relPath, about, options = {}) {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
|
|
121
|
-
const
|
|
122
|
+
const label = about ? String(about).replace(/\s+/g, " ").slice(0, 120) : "";
|
|
123
|
+
const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (label ? " for \"" + label + "\"" : "") + " · read(path, line, count) for a folded body";
|
|
124
|
+
let text = title + "\n" + parts.join("\n");
|
|
122
125
|
|
|
123
|
-
|
|
126
|
+
if (text.length > opts.maxChars) {
|
|
127
|
+
const end = text.lastIndexOf("\n", Math.max(0, opts.maxChars - 160));
|
|
128
|
+
|
|
129
|
+
text = (end > title.length ? text.slice(0, end) : text.slice(0, opts.maxChars)) + "\n … outline truncated; use read(path, line, count) for later declarations";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { text, expanded: expanded.size, declarations: spans.length };
|
|
124
133
|
}
|
|
@@ -21,6 +21,7 @@ const WATCH_DEBOUNCE_MS = 150;
|
|
|
21
21
|
const MAX_INDEXED_FILES = 4000;
|
|
22
22
|
|
|
23
23
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
24
|
+
const MAX_ENTRY_CACHE_BYTES = 64 * 1024 * 1024;
|
|
24
25
|
|
|
25
26
|
const BINARY_EXT = new Set([
|
|
26
27
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
|
|
@@ -34,11 +35,13 @@ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
|
34
35
|
|
|
35
36
|
const EMPTY = Object.freeze([]);
|
|
36
37
|
|
|
37
|
-
const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
|
|
38
|
+
const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(?:(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)|([A-Z][A-Z0-9_$]*)\s*(?::[^=\n]+)?=)/;
|
|
38
39
|
|
|
39
|
-
/** Declared identifier on a line (function/class/
|
|
40
|
+
/** Declared identifier on a line (function/class/UPPER_CASE constant/…), or ""; the same rule snap and grep use. */
|
|
40
41
|
export function declaredName(line) {
|
|
41
|
-
|
|
42
|
+
const match = DEF_PATTERN.exec(String(line).trim());
|
|
43
|
+
|
|
44
|
+
return match?.[2] ?? match?.[3] ?? "";
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
function isTextCandidate(filePath) {
|
|
@@ -71,7 +74,9 @@ function globGroup(glob, i, open) {
|
|
|
71
74
|
|
|
72
75
|
if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
|
|
73
76
|
const inner = glob.slice(i + 1, end);
|
|
74
|
-
const source = open === "{"
|
|
77
|
+
const source = open === "{"
|
|
78
|
+
? "(?:" + inner.split(",").map(globBody).join("|") + ")"
|
|
79
|
+
: "[" + (inner.startsWith("!") ? "^" + inner.slice(1) : inner) + "]";
|
|
75
80
|
|
|
76
81
|
return [source, end + 1];
|
|
77
82
|
}
|
|
@@ -137,6 +142,7 @@ export class WorkspaceIndex {
|
|
|
137
142
|
this.runCommand = runCommand;
|
|
138
143
|
this.lists = new Map();
|
|
139
144
|
this.entries = new Map();
|
|
145
|
+
this.entryBytes = 0;
|
|
140
146
|
this.watchers = new Map();
|
|
141
147
|
this.frecency = new Frecency();
|
|
142
148
|
this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
|
|
@@ -145,6 +151,11 @@ export class WorkspaceIndex {
|
|
|
145
151
|
|
|
146
152
|
invalidate() {
|
|
147
153
|
this.lists.clear();
|
|
154
|
+
this.gitModified.clear();
|
|
155
|
+
|
|
156
|
+
for (const entry of this.entries.values()) this.entryBytes -= entry.weight ?? 0;
|
|
157
|
+
this.entries.clear();
|
|
158
|
+
this.entryBytes = 0;
|
|
148
159
|
}
|
|
149
160
|
|
|
150
161
|
/** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
|
|
@@ -166,12 +177,17 @@ export class WorkspaceIndex {
|
|
|
166
177
|
timer = null;
|
|
167
178
|
this.lists.clear();
|
|
168
179
|
this.gitModified.delete(root);
|
|
180
|
+
this.entries.clear();
|
|
181
|
+
this.entryBytes = 0;
|
|
169
182
|
}, WATCH_DEBOUNCE_MS);
|
|
183
|
+
timer.unref?.();
|
|
170
184
|
});
|
|
171
185
|
|
|
172
186
|
watcher.on("error", () => {
|
|
173
187
|
this.watchers.set(root, false);
|
|
174
188
|
this.lists.clear();
|
|
189
|
+
this.entries.clear();
|
|
190
|
+
this.entryBytes = 0;
|
|
175
191
|
});
|
|
176
192
|
|
|
177
193
|
if (isFunction(watcher.unref)) watcher.unref();
|
|
@@ -196,8 +212,21 @@ export class WorkspaceIndex {
|
|
|
196
212
|
const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
|
|
197
213
|
|
|
198
214
|
if (res.exitCode === 0) {
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
const rows = res.stdout.split("\0");
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < rows.length; i++) {
|
|
218
|
+
const row = rows[i];
|
|
219
|
+
|
|
220
|
+
if (row.length <= 3) continue;
|
|
221
|
+
const status = row.slice(0, 2);
|
|
222
|
+
const file = row.slice(3);
|
|
223
|
+
|
|
224
|
+
if (file) set.add(file);
|
|
225
|
+
if ((status.includes("R") || status.includes("C")) && i + 1 < rows.length) {
|
|
226
|
+
const target = rows[++i];
|
|
227
|
+
|
|
228
|
+
if (target) set.add(target);
|
|
229
|
+
}
|
|
201
230
|
}
|
|
202
231
|
}
|
|
203
232
|
} catch {}
|
|
@@ -229,7 +258,7 @@ export class WorkspaceIndex {
|
|
|
229
258
|
const args = ["rg", "--files"];
|
|
230
259
|
|
|
231
260
|
if (includeHidden) args.push("--hidden");
|
|
232
|
-
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
261
|
+
args.push("-g", "!.git/**", "-g", "!**/.git/**", "--", root);
|
|
233
262
|
let files = [];
|
|
234
263
|
let error;
|
|
235
264
|
let truncated = false;
|
|
@@ -261,26 +290,107 @@ export class WorkspaceIndex {
|
|
|
261
290
|
try {
|
|
262
291
|
stat = fs.statSync(filePath);
|
|
263
292
|
} catch {
|
|
293
|
+
const previous = this.entries.get(filePath);
|
|
294
|
+
|
|
295
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
264
296
|
this.entries.delete(filePath);
|
|
265
297
|
|
|
266
298
|
return null;
|
|
267
299
|
}
|
|
268
300
|
|
|
269
|
-
if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
|
|
301
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
|
|
302
|
+
const previous = this.entries.get(filePath);
|
|
303
|
+
|
|
304
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
305
|
+
this.entries.delete(filePath);
|
|
306
|
+
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
270
309
|
const cached = this.entries.get(filePath);
|
|
271
310
|
|
|
272
|
-
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
|
|
311
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
312
|
+
this.entries.delete(filePath);
|
|
313
|
+
this.entries.set(filePath, cached);
|
|
314
|
+
|
|
315
|
+
return cached;
|
|
316
|
+
}
|
|
273
317
|
let text;
|
|
318
|
+
let actual = stat;
|
|
274
319
|
|
|
275
320
|
try {
|
|
276
|
-
|
|
321
|
+
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
actual = fs.fstatSync(fd);
|
|
325
|
+
if (!actual.isFile() || actual.size > MAX_FILE_BYTES) {
|
|
326
|
+
const previous = this.entries.get(filePath);
|
|
327
|
+
|
|
328
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
329
|
+
this.entries.delete(filePath);
|
|
330
|
+
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
334
|
+
let offset = 0;
|
|
335
|
+
|
|
336
|
+
while (offset < buffer.length) {
|
|
337
|
+
const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
338
|
+
|
|
339
|
+
if (read <= 0) break;
|
|
340
|
+
offset += read;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (offset > MAX_FILE_BYTES) {
|
|
344
|
+
const previous = this.entries.get(filePath);
|
|
345
|
+
|
|
346
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
347
|
+
this.entries.delete(filePath);
|
|
348
|
+
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
actual = fs.fstatSync(fd);
|
|
352
|
+
if (!actual.isFile() || actual.size !== offset) {
|
|
353
|
+
const previous = this.entries.get(filePath);
|
|
354
|
+
|
|
355
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
356
|
+
this.entries.delete(filePath);
|
|
357
|
+
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
text = buffer.subarray(0, offset).toString("utf8");
|
|
361
|
+
} finally { fs.closeSync(fd); }
|
|
277
362
|
} catch {
|
|
363
|
+
const previous = this.entries.get(filePath);
|
|
364
|
+
|
|
365
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
366
|
+
this.entries.delete(filePath);
|
|
367
|
+
|
|
278
368
|
return null;
|
|
279
369
|
}
|
|
280
370
|
|
|
281
|
-
if (text.includes("\0"))
|
|
282
|
-
|
|
371
|
+
if (text.includes("\0")) {
|
|
372
|
+
const previous = this.entries.get(filePath);
|
|
373
|
+
|
|
374
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
375
|
+
this.entries.delete(filePath);
|
|
376
|
+
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const created = { text, lower: text.toLowerCase(), mtimeMs: actual.mtimeMs, size: actual.size, weight: Math.max(1, actual.size) * 2, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
380
|
+
const previous = this.entries.get(filePath);
|
|
381
|
+
|
|
382
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
383
|
+
this.entries.delete(filePath);
|
|
283
384
|
this.entries.set(filePath, created);
|
|
385
|
+
this.entryBytes += created.weight;
|
|
386
|
+
|
|
387
|
+
while (this.entryBytes > MAX_ENTRY_CACHE_BYTES && this.entries.size > 1) {
|
|
388
|
+
const oldest = this.entries.keys().next().value;
|
|
389
|
+
const evicted = this.entries.get(oldest);
|
|
390
|
+
|
|
391
|
+
this.entries.delete(oldest);
|
|
392
|
+
this.entryBytes -= evicted?.weight ?? 0;
|
|
393
|
+
}
|
|
284
394
|
|
|
285
395
|
return created;
|
|
286
396
|
}
|
|
@@ -300,7 +410,8 @@ export class WorkspaceIndex {
|
|
|
300
410
|
for (let i = 0; i < raw.length; i++) {
|
|
301
411
|
const trimmed = raw[i].trim();
|
|
302
412
|
lower[i] = trimmed.toLowerCase();
|
|
303
|
-
|
|
413
|
+
const declared = DEF_PATTERN.exec(trimmed);
|
|
414
|
+
defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
|
|
304
415
|
idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
|
|
305
416
|
}
|
|
306
417
|
|
|
@@ -366,9 +477,33 @@ export class WorkspaceIndex {
|
|
|
366
477
|
|
|
367
478
|
for (const filePath of files) {
|
|
368
479
|
const pending = overlayText(filePath);
|
|
369
|
-
|
|
480
|
+
let e = null;
|
|
481
|
+
|
|
482
|
+
if (pending === undefined) e = this.entry(filePath);
|
|
483
|
+
else if (Buffer.byteLength(pending, "utf8") <= MAX_FILE_BYTES) e = WorkspaceIndex.fromText(filePath, pending);
|
|
484
|
+
else {
|
|
485
|
+
const rel = relativeSlash(root, filePath);
|
|
486
|
+
let start = 0;
|
|
487
|
+
let line = 0;
|
|
488
|
+
|
|
489
|
+
while (start <= pending.length) {
|
|
490
|
+
const end = pending.indexOf("\n", start);
|
|
491
|
+
const stop = end === -1 ? pending.length : end;
|
|
492
|
+
const text = pending.slice(start, stop).replace(/\r$/, "");
|
|
493
|
+
|
|
494
|
+
line++;
|
|
495
|
+
if (regex.test(text)) out.push({ rel, line, text, def: false });
|
|
496
|
+
if (end === -1) break;
|
|
497
|
+
start = end + 1;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!e) continue;
|
|
504
|
+
const lineAnchored = /\^|\$/.test(regex.source.replace(/\\[\^$]|\[[^\]]*\]/g, ""));
|
|
370
505
|
|
|
371
|
-
if (!
|
|
506
|
+
if (!lineAnchored && !regex.test(e.text)) continue;
|
|
372
507
|
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
373
508
|
const rel = relativeSlash(root, filePath);
|
|
374
509
|
|
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("/"));
|