pi-supernova 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -11
- package/docs/CHANGELOG.md +150 -0
- package/docs/TOKEN_COSTS.md +71 -29
- package/index.js +126 -82
- package/package.json +2 -2
- package/src/adapters/bash.js +73 -0
- package/src/adapters/edit.js +249 -0
- package/src/adapters/errors.js +31 -0
- package/src/adapters/index.js +31 -0
- package/src/adapters/list.js +102 -0
- package/src/adapters/read.js +805 -0
- package/src/adapters/refs.js +41 -0
- package/src/adapters/write.js +96 -0
- package/src/bridge/catalog.js +30 -220
- package/src/bridge/host-bridge.js +142 -1032
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -188
- package/src/context/evidence.js +142 -70
- package/src/context/fuzzy.js +61 -22
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +26 -12
- package/src/context/repo-index.js +242 -71
- package/src/context/search.js +189 -56
- package/src/context/snap.js +306 -150
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +29 -14
- package/src/contract/bash.js +31 -0
- package/src/contract/edit.js +95 -0
- package/src/contract/read.js +220 -0
- package/src/fs/check.js +19 -7
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +97 -51
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +289 -162
- package/src/fs/workspace.js +122 -105
- package/src/output/bottleneck.js +211 -107
- package/src/output/format.js +112 -63
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +306 -213
- package/src/runtime/parallel.js +99 -63
- package/src/runtime/program-batch.js +189 -69
- package/src/runtime/program-file.js +6 -3
- package/src/runtime/reference.js +13 -12
- package/src/runtime/runtime.js +327 -176
- package/src/shared/decode.js +61 -27
- package/src/ui/omp-frame.js +70 -46
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +242 -146
package/src/context/ledger.js
CHANGED
|
@@ -39,19 +39,23 @@ function newHistory() {
|
|
|
39
39
|
// Experimental candidate collection, not proof of provider-visible retention:
|
|
40
40
|
// AgentMessage metadata and later context transformations can hide these strings.
|
|
41
41
|
// Keep this optimization opt-in until exact outgoing citation targets are validated.
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (value.length > MAX_OBSERVED_LINE && !value.includes("\n")) return;
|
|
42
|
+
function retainString(value, retained) {
|
|
43
|
+
// Every line, not only substantive ones. A run may span a blank or short line,
|
|
44
|
+
// so isRetained must be exact per line or the run truncates there. An oversized
|
|
45
|
+
// line is never stored: it cannot sit inside a six-line run, and this is what
|
|
46
|
+
// keeps base64 image payloads out of the retention set.
|
|
47
|
+
if (value.length > MAX_OBSERVED_LINE && !value.includes("\n")) return;
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
for (const line of value.split("\n")) {
|
|
50
|
+
if (retained.size >= MAX_STORED_LINES) return;
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
if (line.length <= MAX_OBSERVED_LINE) retained.add(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectRetained(value, retained, depth = 0) {
|
|
57
|
+
if (isString(value)) {
|
|
58
|
+
retainString(value, retained);
|
|
55
59
|
|
|
56
60
|
return;
|
|
57
61
|
}
|
|
@@ -174,7 +178,7 @@ export class SeenLedger {
|
|
|
174
178
|
return count;
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
|
|
181
|
+
bestCandidateRun(lines, hashes, index, call) {
|
|
178
182
|
const candidates = this.occurrences.get(hashes[index]);
|
|
179
183
|
|
|
180
184
|
if (!candidates) return null;
|
|
@@ -186,12 +190,23 @@ export class SeenLedger {
|
|
|
186
190
|
if (length >= MIN_RUN && (!best || length > best.length)) best = { ...candidate, length };
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
|
|
193
|
+
return best;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
substantiveCount(lines, index, length) {
|
|
190
197
|
let count = 0;
|
|
191
198
|
|
|
192
|
-
for (let i = index; i < index +
|
|
199
|
+
for (let i = index; i < index + length; i++) if (collapsible(lines[i])) count++;
|
|
193
200
|
|
|
194
|
-
return count
|
|
201
|
+
return count;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
longestRun(lines, hashes, index, call) {
|
|
205
|
+
const best = this.bestCandidateRun(lines, hashes, index, call);
|
|
206
|
+
|
|
207
|
+
if (!best) return null;
|
|
208
|
+
|
|
209
|
+
return this.substantiveCount(lines, index, best.length) >= MIN_SUBSTANTIVE ? best : null;
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
citation(lines, index, run) {
|
|
@@ -245,19 +260,14 @@ export class SeenLedger {
|
|
|
245
260
|
return sent;
|
|
246
261
|
}
|
|
247
262
|
|
|
248
|
-
|
|
249
|
-
if (this.window === 0 || call <= this.history.latestCall - this.window || lines.length > MAX_STORED_LINES) return;
|
|
250
|
-
|
|
251
|
-
if (this.results.has(call)) this.forget(call);
|
|
252
|
-
|
|
263
|
+
evictForCapacity(incoming) {
|
|
253
264
|
for (const old of [...this.results.keys()].sort((a, b) => a - b)) {
|
|
254
|
-
if (this.storedLines +
|
|
265
|
+
if (this.storedLines + incoming <= MAX_STORED_LINES) break;
|
|
255
266
|
this.forget(old);
|
|
256
267
|
}
|
|
268
|
+
}
|
|
257
269
|
|
|
258
|
-
|
|
259
|
-
const origins = lines.map(line => this.origins.get(line));
|
|
260
|
-
|
|
270
|
+
indexCollapsible(lines, hashes, call) {
|
|
261
271
|
for (let i = 0; i < lines.length; i++) {
|
|
262
272
|
if (!collapsible(lines[i])) continue;
|
|
263
273
|
let list = this.occurrences.get(hashes[i]);
|
|
@@ -265,7 +275,16 @@ export class SeenLedger {
|
|
|
265
275
|
if (!list) this.occurrences.set(hashes[i], (list = []));
|
|
266
276
|
list.push({ call, index: i });
|
|
267
277
|
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
remember(call, lines) {
|
|
281
|
+
if (this.window === 0 || call <= this.history.latestCall - this.window || lines.length > MAX_STORED_LINES) return;
|
|
268
282
|
|
|
283
|
+
if (this.results.has(call)) this.forget(call);
|
|
284
|
+
this.evictForCapacity(lines.length);
|
|
285
|
+
const hashes = Uint32Array.from(lines, hashLine);
|
|
286
|
+
const origins = lines.map(line => this.origins.get(line));
|
|
287
|
+
this.indexCollapsible(lines, hashes, call);
|
|
269
288
|
this.results.set(call, { hashes, lines, origins });
|
|
270
289
|
this.history.storedLines += lines.length;
|
|
271
290
|
}
|
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();
|
|
@@ -96,16 +97,7 @@ function focusedText(raw, lower, stems, relPath, opts) {
|
|
|
96
97
|
* @param entry index entry (text + cached lines/surface)
|
|
97
98
|
* @param about question or symbol; empty ⇒ pure skeleton (every body folded)
|
|
98
99
|
*/
|
|
99
|
-
|
|
100
|
-
const opts = { ...OUTLINE_DEFAULTS, ...options };
|
|
101
|
-
const { raw, lower } = WorkspaceIndex.linesOf(entry);
|
|
102
|
-
const lineCount = raw.length;
|
|
103
|
-
const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
|
|
104
|
-
const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
|
|
105
|
-
|
|
106
|
-
if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
|
|
107
|
-
const expanded = chooseExpanded(spans, lower, stems, raw, opts);
|
|
108
|
-
|
|
100
|
+
function outlineHeader(spans, raw, opts) {
|
|
109
101
|
const parts = [];
|
|
110
102
|
const headerEnd = Math.min(spans[0].start - 1, opts.headerLines);
|
|
111
103
|
|
|
@@ -117,8 +109,30 @@ export function outlineFile(entry, relPath, about, options = {}) {
|
|
|
117
109
|
if (spans[0].start - 1 > opts.headerLines) parts.push(" … " + (spans[0].start - 1 - opts.headerLines) + " more header lines");
|
|
118
110
|
}
|
|
119
111
|
|
|
112
|
+
return parts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clipOutline(text, title, maxChars) {
|
|
116
|
+
if (text.length <= maxChars) return text;
|
|
117
|
+
const end = text.lastIndexOf("\n", Math.max(0, maxChars - 160));
|
|
118
|
+
|
|
119
|
+
return (end > title.length ? text.slice(0, end) : text.slice(0, maxChars)) + "\n … outline truncated; use read(path, line, count) for later declarations";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function outlineFile(entry, relPath, about, options = {}) {
|
|
123
|
+
const opts = { ...OUTLINE_DEFAULTS, ...options };
|
|
124
|
+
const { raw, lower } = WorkspaceIndex.linesOf(entry);
|
|
125
|
+
const lineCount = raw.length;
|
|
126
|
+
const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
|
|
127
|
+
const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
|
|
128
|
+
|
|
129
|
+
if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
|
|
130
|
+
const expanded = chooseExpanded(spans, lower, stems, raw, opts);
|
|
131
|
+
const parts = outlineHeader(spans, raw, opts);
|
|
132
|
+
|
|
120
133
|
for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
|
|
121
|
-
const
|
|
134
|
+
const label = about ? String(about).replace(/\s+/g, " ").slice(0, 120) : "";
|
|
135
|
+
const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (label ? " for \"" + label + "\"" : "") + " · read(path, line, count) for a folded body";
|
|
122
136
|
|
|
123
|
-
return { text: title + "\n" + parts.join("\n"), expanded: expanded.size, declarations: spans.length };
|
|
137
|
+
return { text: clipOutline(title + "\n" + parts.join("\n"), title, opts.maxChars), expanded: expanded.size, declarations: spans.length };
|
|
124
138
|
}
|
|
@@ -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
|
}
|
|
@@ -96,35 +101,41 @@ export function globToRegExp(glob) {
|
|
|
96
101
|
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const base = indentOf(start - 1);
|
|
103
|
-
let end = start;
|
|
104
|
-
|
|
105
|
-
for (let i = start; i < lineCount; i++) {
|
|
106
|
-
if (lower[i] === "") { end = i + 1; continue; }
|
|
107
|
-
if (indentOf(i) <= base) break;
|
|
108
|
-
end = i + 1;
|
|
109
|
-
}
|
|
104
|
+
function lineIndent(raw, i) {
|
|
105
|
+
return raw[i].length - raw[i].trimStart().length;
|
|
106
|
+
}
|
|
110
107
|
|
|
111
|
-
|
|
108
|
+
function pythonDeclarationEnd(raw, lower, start, lineCount) {
|
|
109
|
+
const base = lineIndent(raw, start - 1);
|
|
110
|
+
let end = start;
|
|
111
|
+
|
|
112
|
+
for (let i = start; i < lineCount; i++) {
|
|
113
|
+
if (lower[i] === "") { end = i + 1; continue; }
|
|
114
|
+
if (lineIndent(raw, i) <= base) break;
|
|
115
|
+
end = i + 1;
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
return Math.min(end, lineCount);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function braceDelta(text) {
|
|
114
122
|
let depth = 0;
|
|
115
123
|
|
|
116
|
-
for (const ch of
|
|
124
|
+
for (const ch of text) {
|
|
117
125
|
if (ch === "{") depth++;
|
|
118
126
|
else if (ch === "}") depth--;
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
return depth;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function braceDeclarationEnd(raw, start, lineCount) {
|
|
133
|
+
let depth = braceDelta(raw[start - 1] ?? "");
|
|
134
|
+
|
|
121
135
|
if (depth <= 0) return start;
|
|
122
136
|
|
|
123
137
|
for (let i = start; i < raw.length; i++) {
|
|
124
|
-
|
|
125
|
-
if (ch === "{") depth++;
|
|
126
|
-
else if (ch === "}") depth--;
|
|
127
|
-
}
|
|
138
|
+
depth += braceDelta(raw[i]);
|
|
128
139
|
|
|
129
140
|
if (depth <= 0) return i + 1;
|
|
130
141
|
}
|
|
@@ -132,11 +143,95 @@ function declarationEnd(raw, lower, start, lineCount, ext) {
|
|
|
132
143
|
return lineCount;
|
|
133
144
|
}
|
|
134
145
|
|
|
146
|
+
function declarationEnd(raw, lower, start, lineCount, ext) {
|
|
147
|
+
if (ext === ".py") return pythonDeclarationEnd(raw, lower, start, lineCount);
|
|
148
|
+
|
|
149
|
+
return braceDeclarationEnd(raw, start, lineCount);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readFdBuffer(fd, buffer) {
|
|
153
|
+
let offset = 0;
|
|
154
|
+
|
|
155
|
+
while (offset < buffer.length) {
|
|
156
|
+
const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
157
|
+
|
|
158
|
+
if (read <= 0) break;
|
|
159
|
+
offset += read;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return offset;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseRgFiles(res, root) {
|
|
166
|
+
let error;
|
|
167
|
+
|
|
168
|
+
if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
|
|
169
|
+
const truncated = res.outputTruncated === true;
|
|
170
|
+
const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
|
|
171
|
+
|
|
172
|
+
return { files: output.split("\n").flatMap(f => f ? [path.resolve(root, f)] : []).sort(), error, truncated, missing: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function addPorcelainRow(rows, i, set) {
|
|
176
|
+
const row = rows[i];
|
|
177
|
+
|
|
178
|
+
if (row.length <= 3) return i;
|
|
179
|
+
const status = row.slice(0, 2);
|
|
180
|
+
const file = row.slice(3);
|
|
181
|
+
|
|
182
|
+
if (file) set.add(file);
|
|
183
|
+
|
|
184
|
+
if ((status.includes("R") || status.includes("C")) && i + 1 < rows.length) {
|
|
185
|
+
const target = rows[i + 1];
|
|
186
|
+
|
|
187
|
+
if (target) set.add(target);
|
|
188
|
+
|
|
189
|
+
return i + 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return i;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ingestPorcelain(stdout, set) {
|
|
196
|
+
const rows = stdout.split("\0");
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < rows.length; i++) i = addPorcelainRow(rows, i, set);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function grepPendingHuge(pending, rel, regex, out) {
|
|
202
|
+
let start = 0;
|
|
203
|
+
let line = 0;
|
|
204
|
+
|
|
205
|
+
while (start <= pending.length) {
|
|
206
|
+
const end = pending.indexOf("\n", start);
|
|
207
|
+
const stop = end === -1 ? pending.length : end;
|
|
208
|
+
const text = pending.slice(start, stop).replace(/\r$/, "");
|
|
209
|
+
|
|
210
|
+
line++;
|
|
211
|
+
if (regex.test(text)) out.push({ rel, line, text, def: false });
|
|
212
|
+
if (end === -1) break;
|
|
213
|
+
start = end + 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function grepEntryRows(e, filePath, root, regex, nameRegex, out) {
|
|
218
|
+
const lineAnchored = /\^|\$/.test(regex.source.replace(/\\[\^$]|\[[^\]]*\]/g, ""));
|
|
219
|
+
|
|
220
|
+
if (!lineAnchored && !regex.test(e.text)) return;
|
|
221
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
222
|
+
const rel = relativeSlash(root, filePath);
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i < raw.length; i++) {
|
|
225
|
+
if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
135
229
|
export class WorkspaceIndex {
|
|
136
230
|
constructor(runCommand) {
|
|
137
231
|
this.runCommand = runCommand;
|
|
138
232
|
this.lists = new Map();
|
|
139
233
|
this.entries = new Map();
|
|
234
|
+
this.entryBytes = 0;
|
|
140
235
|
this.watchers = new Map();
|
|
141
236
|
this.frecency = new Frecency();
|
|
142
237
|
this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
|
|
@@ -145,6 +240,11 @@ export class WorkspaceIndex {
|
|
|
145
240
|
|
|
146
241
|
invalidate() {
|
|
147
242
|
this.lists.clear();
|
|
243
|
+
this.gitModified.clear();
|
|
244
|
+
|
|
245
|
+
for (const entry of this.entries.values()) this.entryBytes -= entry.weight ?? 0;
|
|
246
|
+
this.entries.clear();
|
|
247
|
+
this.entryBytes = 0;
|
|
148
248
|
}
|
|
149
249
|
|
|
150
250
|
/** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
|
|
@@ -153,6 +253,8 @@ export class WorkspaceIndex {
|
|
|
153
253
|
this.lastTouched = relPath;
|
|
154
254
|
}
|
|
155
255
|
|
|
256
|
+
getEntryBytes() { return this.entryBytes; }
|
|
257
|
+
|
|
156
258
|
watch(root) {
|
|
157
259
|
if (this.watchers.has(root)) return this.watchers.get(root);
|
|
158
260
|
let ok = false;
|
|
@@ -166,12 +268,17 @@ export class WorkspaceIndex {
|
|
|
166
268
|
timer = null;
|
|
167
269
|
this.lists.clear();
|
|
168
270
|
this.gitModified.delete(root);
|
|
271
|
+
this.entries.clear();
|
|
272
|
+
this.entryBytes = 0;
|
|
169
273
|
}, WATCH_DEBOUNCE_MS);
|
|
274
|
+
timer.unref?.();
|
|
170
275
|
});
|
|
171
276
|
|
|
172
277
|
watcher.on("error", () => {
|
|
173
278
|
this.watchers.set(root, false);
|
|
174
279
|
this.lists.clear();
|
|
280
|
+
this.entries.clear();
|
|
281
|
+
this.entryBytes = 0;
|
|
175
282
|
});
|
|
176
283
|
|
|
177
284
|
if (isFunction(watcher.unref)) watcher.unref();
|
|
@@ -195,11 +302,7 @@ export class WorkspaceIndex {
|
|
|
195
302
|
try {
|
|
196
303
|
const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
|
|
197
304
|
|
|
198
|
-
if (res.exitCode === 0)
|
|
199
|
-
for (const row of res.stdout.split("\0")) {
|
|
200
|
-
if (row.length > 3) set.add(row.slice(3));
|
|
201
|
-
}
|
|
202
|
-
}
|
|
305
|
+
if (res.exitCode === 0) ingestPorcelain(res.stdout, set);
|
|
203
306
|
} catch {}
|
|
204
307
|
|
|
205
308
|
this.gitModified.set(root, set);
|
|
@@ -219,6 +322,21 @@ export class WorkspaceIndex {
|
|
|
219
322
|
}
|
|
220
323
|
}
|
|
221
324
|
|
|
325
|
+
async listFilesWithRg(root, includeHidden, signal) {
|
|
326
|
+
const args = ["rg", "--files"];
|
|
327
|
+
|
|
328
|
+
if (includeHidden) args.push("--hidden");
|
|
329
|
+
args.push("-g", "!.git/**", "-g", "!**/.git/**", "--", root);
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
return parseRgFiles(await this.runCommand(args, { cwd: root, timeoutMs: 15_000, signal }), root);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
signal?.throwIfAborted();
|
|
335
|
+
|
|
336
|
+
return { files: [], error: err.message, truncated: false, missing: !fs.existsSync(root) };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
222
340
|
/** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
|
|
223
341
|
async files(root, includeHidden = false, signal) {
|
|
224
342
|
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
@@ -226,65 +344,112 @@ export class WorkspaceIndex {
|
|
|
226
344
|
const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
|
|
227
345
|
|
|
228
346
|
if (cached && Date.now() - cached.at < ttl) return cached.files;
|
|
229
|
-
const
|
|
347
|
+
const listed = await this.listFilesWithRg(root, includeHidden, signal);
|
|
348
|
+
this.lists.set(key, { ...listed, at: Date.now() });
|
|
230
349
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
let files = [];
|
|
234
|
-
let error;
|
|
235
|
-
let truncated = false;
|
|
236
|
-
let missing = false;
|
|
350
|
+
return listed.files;
|
|
351
|
+
}
|
|
237
352
|
|
|
238
|
-
|
|
239
|
-
|
|
353
|
+
dropCached(filePath) {
|
|
354
|
+
const previous = this.entries.get(filePath);
|
|
240
355
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
files = output.split("\n").filter(Boolean).map(f => path.resolve(root, f)).sort();
|
|
245
|
-
} catch (err) {
|
|
246
|
-
signal?.throwIfAborted();
|
|
247
|
-
error = err.message;
|
|
248
|
-
missing = !fs.existsSync(root);
|
|
249
|
-
}
|
|
356
|
+
if (previous) this.entryBytes -= previous.weight ?? 0;
|
|
357
|
+
this.entries.delete(filePath);
|
|
358
|
+
}
|
|
250
359
|
|
|
251
|
-
|
|
360
|
+
rejectEntry(filePath) {
|
|
361
|
+
this.dropCached(filePath);
|
|
252
362
|
|
|
253
|
-
return
|
|
363
|
+
return null;
|
|
254
364
|
}
|
|
255
365
|
|
|
256
|
-
|
|
257
|
-
entry(filePath) {
|
|
258
|
-
if (!isTextCandidate(filePath)) return null;
|
|
259
|
-
let stat;
|
|
260
|
-
|
|
366
|
+
statOrNull(filePath) {
|
|
261
367
|
try {
|
|
262
|
-
|
|
368
|
+
return fs.statSync(filePath);
|
|
263
369
|
} catch {
|
|
264
|
-
this.entries.delete(filePath);
|
|
265
|
-
|
|
266
370
|
return null;
|
|
267
371
|
}
|
|
372
|
+
}
|
|
268
373
|
|
|
269
|
-
|
|
374
|
+
cachedHit(filePath, stat) {
|
|
270
375
|
const cached = this.entries.get(filePath);
|
|
271
376
|
|
|
272
|
-
if (cached
|
|
273
|
-
|
|
377
|
+
if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) return null;
|
|
378
|
+
this.entries.delete(filePath);
|
|
379
|
+
this.entries.set(filePath, cached);
|
|
380
|
+
|
|
381
|
+
return cached;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
readFdText(filePath, fd) {
|
|
385
|
+
let actual = fs.fstatSync(fd);
|
|
386
|
+
|
|
387
|
+
if (!actual.isFile() || actual.size > MAX_FILE_BYTES) return this.rejectEntry(filePath);
|
|
388
|
+
// One reusable scratch read per index: allocating 512 KiB per file lets
|
|
389
|
+
// thousands of dead buffers pile up as RSS before a major GC notices.
|
|
390
|
+
this.scratchRead ??= Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
391
|
+
const offset = readFdBuffer(fd, this.scratchRead);
|
|
392
|
+
|
|
393
|
+
if (offset > MAX_FILE_BYTES) return this.rejectEntry(filePath);
|
|
394
|
+
actual = fs.fstatSync(fd);
|
|
395
|
+
|
|
396
|
+
if (!actual.isFile() || actual.size !== offset) return this.rejectEntry(filePath);
|
|
397
|
+
|
|
398
|
+
return { text: this.scratchRead.subarray(0, offset).toString("utf8"), actual };
|
|
399
|
+
}
|
|
274
400
|
|
|
401
|
+
readIndexedText(filePath) {
|
|
275
402
|
try {
|
|
276
|
-
|
|
403
|
+
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
return this.readFdText(filePath, fd);
|
|
407
|
+
} finally { fs.closeSync(fd); }
|
|
277
408
|
} catch {
|
|
278
|
-
return
|
|
409
|
+
return this.rejectEntry(filePath);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
evictOverflow() {
|
|
414
|
+
while (this.entryBytes > MAX_ENTRY_CACHE_BYTES && this.entries.size > 1) {
|
|
415
|
+
const oldest = this.entries.keys().next().value;
|
|
416
|
+
const evicted = this.entries.get(oldest);
|
|
417
|
+
|
|
418
|
+
this.entries.delete(oldest);
|
|
419
|
+
this.entryBytes -= evicted?.weight ?? 0;
|
|
279
420
|
}
|
|
421
|
+
}
|
|
280
422
|
|
|
281
|
-
|
|
282
|
-
const created = { text, lower: text.toLowerCase(), mtimeMs:
|
|
423
|
+
storeCreated(filePath, loaded) {
|
|
424
|
+
const created = { text: loaded.text, lower: loaded.text.toLowerCase(), mtimeMs: loaded.actual.mtimeMs, size: loaded.actual.size, weight: Math.max(1, loaded.actual.size) * 2, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
425
|
+
this.dropCached(filePath);
|
|
283
426
|
this.entries.set(filePath, created);
|
|
427
|
+
this.entryBytes += created.weight;
|
|
428
|
+
this.evictOverflow();
|
|
284
429
|
|
|
285
430
|
return created;
|
|
286
431
|
}
|
|
287
432
|
|
|
433
|
+
/** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
|
|
434
|
+
entry(filePath) {
|
|
435
|
+
if (!isTextCandidate(filePath)) return null;
|
|
436
|
+
const stat = this.statOrNull(filePath);
|
|
437
|
+
|
|
438
|
+
if (!stat) return this.rejectEntry(filePath);
|
|
439
|
+
|
|
440
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return this.rejectEntry(filePath);
|
|
441
|
+
const cached = this.cachedHit(filePath, stat);
|
|
442
|
+
|
|
443
|
+
if (cached) return cached;
|
|
444
|
+
const loaded = this.readIndexedText(filePath);
|
|
445
|
+
|
|
446
|
+
if (!loaded) return null;
|
|
447
|
+
|
|
448
|
+
if (loaded.text.includes("\0")) return this.rejectEntry(filePath);
|
|
449
|
+
|
|
450
|
+
return this.storeCreated(filePath, loaded);
|
|
451
|
+
}
|
|
452
|
+
|
|
288
453
|
static fromText(filePath, text) {
|
|
289
454
|
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
290
455
|
}
|
|
@@ -300,7 +465,8 @@ export class WorkspaceIndex {
|
|
|
300
465
|
for (let i = 0; i < raw.length; i++) {
|
|
301
466
|
const trimmed = raw[i].trim();
|
|
302
467
|
lower[i] = trimmed.toLowerCase();
|
|
303
|
-
|
|
468
|
+
const declared = DEF_PATTERN.exec(trimmed);
|
|
469
|
+
defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
|
|
304
470
|
idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
|
|
305
471
|
}
|
|
306
472
|
|
|
@@ -359,22 +525,27 @@ export class WorkspaceIndex {
|
|
|
359
525
|
return hits;
|
|
360
526
|
}
|
|
361
527
|
|
|
528
|
+
resolveGrepEntry(filePath, overlayText, root, regex, out) {
|
|
529
|
+
const pending = overlayText(filePath);
|
|
530
|
+
|
|
531
|
+
if (pending === undefined) return this.entry(filePath);
|
|
532
|
+
|
|
533
|
+
if (Buffer.byteLength(pending, "utf8") <= MAX_FILE_BYTES) return WorkspaceIndex.fromText(filePath, pending);
|
|
534
|
+
grepPendingHuge(pending, relativeSlash(root, filePath), regex, out);
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
362
539
|
/** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
|
|
363
540
|
grepRows(files, regex, root, overlayText = () => undefined) {
|
|
364
541
|
const out = [];
|
|
365
542
|
const nameRegex = new RegExp(regex.source, "i");
|
|
366
543
|
|
|
367
544
|
for (const filePath of files) {
|
|
368
|
-
const
|
|
369
|
-
const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
|
|
370
|
-
|
|
371
|
-
if (!e || !regex.test(e.text)) continue;
|
|
372
|
-
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
373
|
-
const rel = relativeSlash(root, filePath);
|
|
545
|
+
const e = this.resolveGrepEntry(filePath, overlayText, root, regex, out);
|
|
374
546
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
547
|
+
if (!e) continue;
|
|
548
|
+
grepEntryRows(e, filePath, root, regex, nameRegex, out);
|
|
378
549
|
}
|
|
379
550
|
|
|
380
551
|
return out;
|