pi-supernova 0.3.2 → 0.4.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 +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +62 -0
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/context/ledger.js
CHANGED
|
@@ -1,20 +1,79 @@
|
|
|
1
|
+
import { isObject, isString } from "../shared/decode.js";
|
|
2
|
+
|
|
1
3
|
const MIN_RUN = 6;
|
|
4
|
+
|
|
2
5
|
const MIN_SUBSTANTIVE = 4;
|
|
6
|
+
|
|
3
7
|
const MAX_CANDIDATES = 8;
|
|
4
|
-
|
|
8
|
+
|
|
9
|
+
const DEFAULT_WINDOW = 0;
|
|
10
|
+
|
|
5
11
|
const MAX_STORED_LINES = 200_000;
|
|
6
12
|
|
|
13
|
+
const MAX_OBSERVE_DEPTH = 64;
|
|
14
|
+
|
|
15
|
+
// A line this long is a minified blob or an inlined image, not a run of source. It
|
|
16
|
+
// cannot be part of a collapsible run anyway (MIN_RUN needs six lines), and skipping
|
|
17
|
+
// it keeps base64 payloads out of the retention set entirely.
|
|
18
|
+
const MAX_OBSERVED_LINE = 4096;
|
|
19
|
+
|
|
20
|
+
// A collapse marker is a citation, not content. Never index or re-collapse one, so
|
|
21
|
+
// citations cannot nest into a chain that points somewhere the reader must follow.
|
|
22
|
+
const COLLAPSE_MARKER = /^⋯ \d+ lines same as #\d+/;
|
|
23
|
+
|
|
7
24
|
function hashLine(line) {
|
|
8
25
|
let hash = 0x811c9dc5;
|
|
26
|
+
|
|
9
27
|
for (let i = 0; i < line.length; i++) hash = Math.imul(hash ^ line.charCodeAt(i), 0x01000193);
|
|
28
|
+
|
|
10
29
|
return hash >>> 0;
|
|
11
30
|
}
|
|
31
|
+
|
|
12
32
|
function substantive(line) { return line.trim().length >= 8; }
|
|
33
|
+
|
|
13
34
|
function newHistory() {
|
|
14
|
-
return { results: new Map(), occurrences: new Map(), storedLines: 0, latestCall: 0,
|
|
35
|
+
return { results: new Map(), occurrences: new Map(), storedLines: 0, latestCall: 0, retained: null,
|
|
15
36
|
stats: { programs: 0, returnedChars: 0, collapsedChars: 0, collapsedRuns: 0 } };
|
|
16
37
|
}
|
|
17
38
|
|
|
39
|
+
// Experimental candidate collection, not proof of provider-visible retention:
|
|
40
|
+
// AgentMessage metadata and later context transformations can hide these strings.
|
|
41
|
+
// Keep this optimization opt-in until exact outgoing citation targets are validated.
|
|
42
|
+
function collectRetained(value, retained, depth = 0) {
|
|
43
|
+
if (isString(value)) {
|
|
44
|
+
// Every line, not only substantive ones. A run may span a blank or short line,
|
|
45
|
+
// so isRetained must be exact per line or the run truncates there. An oversized
|
|
46
|
+
// line is never stored: it cannot sit inside a six-line run, and this is what
|
|
47
|
+
// keeps base64 image payloads out of the retention set.
|
|
48
|
+
if (value.length > MAX_OBSERVED_LINE && !value.includes("\n")) return;
|
|
49
|
+
|
|
50
|
+
for (const line of value.split("\n")) {
|
|
51
|
+
if (retained.size >= MAX_STORED_LINES) return;
|
|
52
|
+
|
|
53
|
+
if (line.length <= MAX_OBSERVED_LINE) retained.add(line);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (depth >= MAX_OBSERVE_DEPTH) return;
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
for (const item of value) collectRetained(item, retained, depth + 1);
|
|
63
|
+
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// pi's AgentMessage payload is plain objects and arrays, so isObject is the whole
|
|
68
|
+
// traversal contract; anything else is not message text.
|
|
69
|
+
if (!isObject(value)) return;
|
|
70
|
+
|
|
71
|
+
for (const key of Object.keys(value)) collectRetained(value[key], retained, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Content that may be collapsed out of a later result. Citations are excluded. */
|
|
75
|
+
function collapsible(line) { return substantive(line) && !COLLAPSE_MARKER.test(line); }
|
|
76
|
+
|
|
18
77
|
export class SeenLedger {
|
|
19
78
|
constructor({ window = DEFAULT_WINDOW, history } = {}) {
|
|
20
79
|
this.window = window;
|
|
@@ -26,6 +85,31 @@ export class SeenLedger {
|
|
|
26
85
|
get occurrences() { return this.history.occurrences; }
|
|
27
86
|
get storedLines() { return this.history.storedLines; }
|
|
28
87
|
get stats() { return this.history.stats; }
|
|
88
|
+
get retainedLines() { return this.history.retained; }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Record candidate lines from the host's pre-request hook. This observation
|
|
92
|
+
* includes metadata and precedes later transformations, so it is not proof
|
|
93
|
+
* that a citation target will be present in the final provider payload.
|
|
94
|
+
* Until this runs, nothing is collapsible, so the first result of a session is
|
|
95
|
+
* always sent in full.
|
|
96
|
+
*/
|
|
97
|
+
observe(payload) {
|
|
98
|
+
if (this.window === 0) { this.history.retained = null;
|
|
99
|
+
|
|
100
|
+
return; }
|
|
101
|
+
|
|
102
|
+
const retained = new Set();
|
|
103
|
+
collectRetained(payload, retained);
|
|
104
|
+
this.history.retained = retained;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Candidate membership only; not a final-payload retention guarantee. */
|
|
108
|
+
isRetained(line) {
|
|
109
|
+
const retained = this.history.retained;
|
|
110
|
+
|
|
111
|
+
return retained !== null && retained.has(line);
|
|
112
|
+
}
|
|
29
113
|
fork() { return new SeenLedger({ window: this.window, history: this.history }); }
|
|
30
114
|
|
|
31
115
|
reset() {
|
|
@@ -39,54 +123,74 @@ export class SeenLedger {
|
|
|
39
123
|
this.pinned.clear();
|
|
40
124
|
this.stats.programs++;
|
|
41
125
|
this.history.latestCall = Math.max(this.history.latestCall, call);
|
|
126
|
+
|
|
42
127
|
for (const old of this.results.keys()) if (old <= this.history.latestCall - this.window) this.forget(old);
|
|
43
128
|
}
|
|
44
129
|
|
|
45
130
|
forget(call) {
|
|
46
131
|
const entry = this.results.get(call);
|
|
132
|
+
|
|
47
133
|
if (!entry) return;
|
|
134
|
+
|
|
48
135
|
for (const hash of new Set(entry.hashes)) {
|
|
49
136
|
const kept = (this.occurrences.get(hash) ?? []).filter(item => item.call !== call);
|
|
137
|
+
|
|
50
138
|
if (kept.length) this.occurrences.set(hash, kept);
|
|
51
139
|
else this.occurrences.delete(hash);
|
|
52
140
|
}
|
|
141
|
+
|
|
53
142
|
this.history.storedLines -= entry.lines.length;
|
|
54
143
|
this.results.delete(call);
|
|
55
144
|
}
|
|
56
145
|
|
|
57
146
|
recordOrigin(path, firstLine, lines, pin = false) {
|
|
58
147
|
if (this.window === 0) return;
|
|
148
|
+
|
|
59
149
|
for (let i = 0; i < lines.length; i++) {
|
|
60
150
|
const text = lines[i];
|
|
151
|
+
|
|
61
152
|
if (!substantive(text)) continue;
|
|
153
|
+
|
|
62
154
|
if (this.origins.size >= MAX_STORED_LINES && !this.origins.has(text)) this.origins.delete(this.origins.keys().next().value);
|
|
63
155
|
this.origins.set(text, { path, line: firstLine + i });
|
|
156
|
+
|
|
64
157
|
if (pin) this.pinned.add(text);
|
|
65
158
|
}
|
|
66
159
|
}
|
|
67
160
|
|
|
68
161
|
runLength(lines, hashes, candidate, index) {
|
|
69
162
|
const earlier = this.results.get(candidate.call);
|
|
163
|
+
|
|
70
164
|
if (!earlier) return 0;
|
|
71
165
|
let count = 0;
|
|
166
|
+
|
|
72
167
|
while (index + count < lines.length && candidate.index + count < earlier.lines.length
|
|
73
168
|
&& hashes[index + count] === earlier.hashes[candidate.index + count]
|
|
74
169
|
&& lines[index + count] === earlier.lines[candidate.index + count]
|
|
75
|
-
&& !this.pinned.has(lines[index + count])
|
|
170
|
+
&& !this.pinned.has(lines[index + count])
|
|
171
|
+
// Experimental line membership does not establish a visible, ordered target.
|
|
172
|
+
&& this.isRetained(lines[index + count])) count++;
|
|
173
|
+
|
|
76
174
|
return count;
|
|
77
175
|
}
|
|
78
176
|
|
|
79
177
|
longestRun(lines, hashes, index, call) {
|
|
80
178
|
const candidates = this.occurrences.get(hashes[index]);
|
|
179
|
+
|
|
81
180
|
if (!candidates) return null;
|
|
82
181
|
let best = null;
|
|
182
|
+
|
|
83
183
|
for (const candidate of candidates.filter(item => item.call < call).slice(-MAX_CANDIDATES)) {
|
|
84
184
|
const length = this.runLength(lines, hashes, candidate, index);
|
|
185
|
+
|
|
85
186
|
if (length >= MIN_RUN && (!best || length > best.length)) best = { ...candidate, length };
|
|
86
187
|
}
|
|
188
|
+
|
|
87
189
|
if (!best) return null;
|
|
88
190
|
let count = 0;
|
|
89
|
-
|
|
191
|
+
|
|
192
|
+
for (let i = index; i < index + best.length; i++) if (collapsible(lines[i])) count++;
|
|
193
|
+
|
|
90
194
|
return count >= MIN_SUBSTANTIVE ? best : null;
|
|
91
195
|
}
|
|
92
196
|
|
|
@@ -94,56 +198,74 @@ export class SeenLedger {
|
|
|
94
198
|
const earlier = this.results.get(run.call);
|
|
95
199
|
const origin = offset => this.origins.get(lines[index + offset]) ?? earlier?.origins[run.index + offset];
|
|
96
200
|
const first = origin(0);
|
|
201
|
+
|
|
97
202
|
if (!first) return "";
|
|
98
203
|
let expected = first.line;
|
|
204
|
+
|
|
99
205
|
for (let i = 0; i < run.length; i++) {
|
|
100
206
|
const item = origin(i);
|
|
207
|
+
|
|
101
208
|
if (item && (item.path !== first.path || item.line !== expected)) return "";
|
|
102
209
|
expected++;
|
|
103
210
|
}
|
|
211
|
+
|
|
104
212
|
return first.path + ":" + first.line + "–" + (expected - 1);
|
|
105
213
|
}
|
|
106
214
|
|
|
107
215
|
dedupe(text, call) {
|
|
108
216
|
if (this.window === 0) {
|
|
109
217
|
this.stats.returnedChars += text.length;
|
|
218
|
+
|
|
110
219
|
return text;
|
|
111
220
|
}
|
|
221
|
+
|
|
112
222
|
const lines = text.split("\n");
|
|
113
223
|
const hashes = Uint32Array.from(lines, hashLine);
|
|
114
224
|
const out = [];
|
|
115
225
|
let collapsedChars = 0;
|
|
226
|
+
|
|
116
227
|
for (let i = 0; i < lines.length;) {
|
|
117
|
-
const run =
|
|
228
|
+
const run = collapsible(lines[i]) ? this.longestRun(lines, hashes, i, call) : null;
|
|
229
|
+
|
|
118
230
|
if (!run) { out.push(lines[i++]); continue; }
|
|
231
|
+
|
|
119
232
|
const cite = this.citation(lines, i, run);
|
|
120
233
|
out.push("⋯ " + run.length + " lines same as #" + run.call + (cite ? " · " + cite : "") + " ⋯");
|
|
234
|
+
|
|
121
235
|
for (let k = 0; k < run.length; k++) collapsedChars += lines[i + k].length + 1;
|
|
122
236
|
this.stats.collapsedRuns++;
|
|
123
237
|
i += run.length;
|
|
124
238
|
}
|
|
239
|
+
|
|
125
240
|
const sent = out.join("\n");
|
|
126
241
|
this.stats.returnedChars += sent.length;
|
|
127
242
|
this.stats.collapsedChars += collapsedChars;
|
|
128
243
|
this.remember(call, out);
|
|
244
|
+
|
|
129
245
|
return sent;
|
|
130
246
|
}
|
|
131
247
|
|
|
132
248
|
remember(call, lines) {
|
|
133
249
|
if (this.window === 0 || call <= this.history.latestCall - this.window || lines.length > MAX_STORED_LINES) return;
|
|
250
|
+
|
|
134
251
|
if (this.results.has(call)) this.forget(call);
|
|
252
|
+
|
|
135
253
|
for (const old of [...this.results.keys()].sort((a, b) => a - b)) {
|
|
136
254
|
if (this.storedLines + lines.length <= MAX_STORED_LINES) break;
|
|
137
255
|
this.forget(old);
|
|
138
256
|
}
|
|
257
|
+
|
|
139
258
|
const hashes = Uint32Array.from(lines, hashLine);
|
|
140
259
|
const origins = lines.map(line => this.origins.get(line));
|
|
260
|
+
|
|
141
261
|
for (let i = 0; i < lines.length; i++) {
|
|
142
|
-
if (!
|
|
262
|
+
if (!collapsible(lines[i])) continue;
|
|
143
263
|
let list = this.occurrences.get(hashes[i]);
|
|
264
|
+
|
|
144
265
|
if (!list) this.occurrences.set(hashes[i], (list = []));
|
|
145
266
|
list.push({ call, index: i });
|
|
146
267
|
}
|
|
268
|
+
|
|
147
269
|
this.results.set(call, { hashes, lines, origins });
|
|
148
270
|
this.history.storedLines += lines.length;
|
|
149
271
|
}
|
package/src/context/outline.js
CHANGED
|
@@ -14,12 +14,16 @@ function relevance(span, lower, stems) {
|
|
|
14
14
|
if (stems.length === 0) return 0;
|
|
15
15
|
const nameLower = span.name.toLowerCase();
|
|
16
16
|
let score = 0;
|
|
17
|
+
|
|
17
18
|
for (const s of stems) if (nameLower.includes(s)) score += 40;
|
|
19
|
+
|
|
18
20
|
for (let i = span.start - 1; i < span.end; i++) {
|
|
19
21
|
let hits = 0;
|
|
22
|
+
|
|
20
23
|
for (const s of stems) if (lower[i].includes(s)) hits++;
|
|
21
24
|
score += hits * hits * 5;
|
|
22
25
|
}
|
|
26
|
+
|
|
23
27
|
return score;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -30,47 +34,61 @@ function chooseExpanded(spans, lower, stems, raw, opts) {
|
|
|
30
34
|
let budget = opts.maxChars;
|
|
31
35
|
// Weak-match cutoff (as fff's weak-match detector): stop once relevance falls below 40% of the best span.
|
|
32
36
|
const floor = stems.length > 0 ? Math.max(1, scored[0].r * 0.4) : 0;
|
|
37
|
+
|
|
33
38
|
for (const { i, r, chars } of scored) {
|
|
34
39
|
if (expanded.size >= opts.maxExpanded || r < floor) break;
|
|
40
|
+
|
|
35
41
|
if (chars > budget) continue;
|
|
36
42
|
expanded.add(i);
|
|
37
43
|
budget -= chars;
|
|
38
44
|
}
|
|
45
|
+
|
|
39
46
|
return expanded;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
function foldedLine(span) {
|
|
43
50
|
const body = span.end - span.start;
|
|
44
51
|
const sig = span.signature.replace(/\s*\{\s*$/, "");
|
|
52
|
+
|
|
45
53
|
return String(span.start).padStart(5) + " " + sig + (body > 0 ? " … " + body + " lines" : "");
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
function expandedBlock(span, raw, opts) {
|
|
49
57
|
const out = [];
|
|
58
|
+
|
|
50
59
|
for (let l = span.start; l <= span.end; l++) out.push(String(l).padStart(5) + " " + raw[l - 1]);
|
|
51
60
|
const refs = opts.references ? opts.references(span.name, span.start) : [];
|
|
61
|
+
|
|
52
62
|
// Who uses this declaration: the relation a reader would otherwise grep for next.
|
|
53
63
|
if (refs.length) out.push(" // used by: " + refs.slice(0, opts.maxRefs).join(", ") + (refs.length > opts.maxRefs ? " (+" + (refs.length - opts.maxRefs) + ")" : ""));
|
|
64
|
+
|
|
54
65
|
return out.join("\n");
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
function focusedText(raw, lower, stems, relPath, opts) {
|
|
58
69
|
const hits = lower.map((line, i) => ({i, score: stems.filter(s => line.includes(s)).length}))
|
|
59
70
|
.filter(hit => hit.score > 0).sort((a, b) => b.score - a.score || a.i - b.i);
|
|
71
|
+
|
|
60
72
|
const parts = [];
|
|
61
73
|
const covered = new Set();
|
|
62
74
|
let budget = opts.maxChars;
|
|
75
|
+
|
|
63
76
|
for (const {i} of hits) {
|
|
64
77
|
if (parts.length >= opts.maxExpanded) break;
|
|
78
|
+
|
|
65
79
|
if (covered.has(i)) continue;
|
|
66
80
|
const start = Math.max(0, i - 3), end = Math.min(raw.length, i + 4);
|
|
67
81
|
const block = raw.slice(start, end).map((line, j) => String(start + j + 1).padStart(5) + " " + line).join("\n");
|
|
82
|
+
|
|
68
83
|
if (block.length > budget) continue;
|
|
69
84
|
parts.push(block); budget -= block.length;
|
|
85
|
+
|
|
70
86
|
for (let j = start; j < end; j++) covered.add(j);
|
|
71
87
|
}
|
|
88
|
+
|
|
72
89
|
const status = parts.length ? "focused text windows (not a complete file)"
|
|
73
90
|
: hits.length ? "matching text exceeds view budget; first match at line " + (hits[0].i + 1) : "no matching text";
|
|
91
|
+
|
|
74
92
|
return {text: "// " + relPath + " · " + status + "; read(path, line, count) for raw source\n" + parts.join("\n---\n"), expanded: parts.length, declarations: 0};
|
|
75
93
|
}
|
|
76
94
|
|
|
@@ -84,17 +102,23 @@ export function outlineFile(entry, relPath, about, options = {}) {
|
|
|
84
102
|
const lineCount = raw.length;
|
|
85
103
|
const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
|
|
86
104
|
const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
|
|
105
|
+
|
|
87
106
|
if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
|
|
88
107
|
const expanded = chooseExpanded(spans, lower, stems, raw, opts);
|
|
89
108
|
|
|
90
109
|
const parts = [];
|
|
91
110
|
const headerEnd = Math.min(spans[0].start - 1, opts.headerLines);
|
|
111
|
+
|
|
92
112
|
if (headerEnd > 0) {
|
|
93
113
|
const header = raw.slice(0, headerEnd);
|
|
114
|
+
|
|
94
115
|
if (header.length) parts.push(header.map((l, i) => String(i + 1).padStart(5) + " " + l).join("\n"));
|
|
116
|
+
|
|
95
117
|
if (spans[0].start - 1 > opts.headerLines) parts.push(" … " + (spans[0].start - 1 - opts.headerLines) + " more header lines");
|
|
96
118
|
}
|
|
119
|
+
|
|
97
120
|
for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
|
|
98
121
|
const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (about ? " for \"" + about + "\"" : "") + " · read(path, line, count) for a folded body";
|
|
122
|
+
|
|
99
123
|
return { text: title + "\n" + parts.join("\n"), expanded: expanded.size, declarations: spans.length };
|
|
100
124
|
}
|
|
@@ -13,18 +13,27 @@ import { relativeSlash } from "../fs/workspace.js";
|
|
|
13
13
|
|
|
14
14
|
// With a working fs.watch the list only refreshes on change; the TTL is the fallback when watching fails.
|
|
15
15
|
const LIST_TTL_MS = 10_000;
|
|
16
|
+
|
|
16
17
|
const WATCHED_TTL_MS = 5 * 60_000;
|
|
18
|
+
|
|
17
19
|
const WATCH_DEBOUNCE_MS = 150;
|
|
20
|
+
|
|
18
21
|
const MAX_INDEXED_FILES = 4000;
|
|
22
|
+
|
|
19
23
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
24
|
+
|
|
20
25
|
const BINARY_EXT = new Set([
|
|
21
26
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
|
|
22
27
|
".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".wav", ".ogg", ".webm", ".wasm", ".class",
|
|
23
28
|
".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".node", ".lock", ".sqlite", ".sqlite3", ".db",
|
|
24
29
|
]);
|
|
30
|
+
|
|
25
31
|
const REGEX_SPECIAL = /[.+^${}()|\\]/g;
|
|
32
|
+
|
|
26
33
|
const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
34
|
+
|
|
27
35
|
const EMPTY = Object.freeze([]);
|
|
36
|
+
|
|
28
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_$]+)/;
|
|
29
38
|
|
|
30
39
|
/** Declared identifier on a line (function/class/const/…), or ""; the same rule snap and grep use. */
|
|
@@ -39,13 +48,19 @@ function isTextCandidate(filePath) {
|
|
|
39
48
|
/** Translate one glob token at index i → [regexSource, nextIndex]. */
|
|
40
49
|
function globToken(glob, i) {
|
|
41
50
|
const ch = glob[i];
|
|
51
|
+
|
|
42
52
|
if (ch === "*" && glob[i + 1] === "*") {
|
|
43
53
|
const slashAfter = glob[i + 2] === "/";
|
|
54
|
+
|
|
44
55
|
return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
|
|
45
56
|
}
|
|
57
|
+
|
|
46
58
|
if (ch === "*") return ["[^/]*", i + 1];
|
|
59
|
+
|
|
47
60
|
if (ch === "?") return ["[^/]", i + 1];
|
|
61
|
+
|
|
48
62
|
if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
|
|
63
|
+
|
|
49
64
|
return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
|
|
50
65
|
}
|
|
51
66
|
|
|
@@ -53,26 +68,31 @@ function globToken(glob, i) {
|
|
|
53
68
|
function globGroup(glob, i, open) {
|
|
54
69
|
const close = open === "{" ? "}" : "]";
|
|
55
70
|
const end = glob.indexOf(close, i);
|
|
71
|
+
|
|
56
72
|
if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
|
|
57
73
|
const inner = glob.slice(i + 1, end);
|
|
58
74
|
const source = open === "{" ? "(?:" + inner.split(",").map(globBody).join("|") + ")" : "[" + inner + "]";
|
|
75
|
+
|
|
59
76
|
return [source, end + 1];
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
function globBody(glob) {
|
|
63
80
|
let source = "";
|
|
64
81
|
let i = 0;
|
|
82
|
+
|
|
65
83
|
while (i < glob.length) {
|
|
66
84
|
const [piece, next] = globToken(glob, i);
|
|
67
85
|
source += piece;
|
|
68
86
|
i = next;
|
|
69
87
|
}
|
|
88
|
+
|
|
70
89
|
return source;
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
/** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
|
|
74
93
|
export function globToRegExp(glob) {
|
|
75
94
|
const body = globBody(glob);
|
|
95
|
+
|
|
76
96
|
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
77
97
|
}
|
|
78
98
|
|
|
@@ -100,8 +120,10 @@ export class WorkspaceIndex {
|
|
|
100
120
|
watch(root) {
|
|
101
121
|
if (this.watchers.has(root)) return this.watchers.get(root);
|
|
102
122
|
let ok = false;
|
|
123
|
+
|
|
103
124
|
try {
|
|
104
125
|
let timer = null;
|
|
126
|
+
|
|
105
127
|
const watcher = fs.watch(root, { recursive: true, persistent: false }, () => {
|
|
106
128
|
if (timer) return;
|
|
107
129
|
timer = setTimeout(() => {
|
|
@@ -110,39 +132,50 @@ export class WorkspaceIndex {
|
|
|
110
132
|
this.gitModified.delete(root);
|
|
111
133
|
}, WATCH_DEBOUNCE_MS);
|
|
112
134
|
});
|
|
135
|
+
|
|
113
136
|
watcher.on("error", () => {
|
|
114
137
|
this.watchers.set(root, false);
|
|
115
138
|
this.lists.clear();
|
|
116
139
|
});
|
|
140
|
+
|
|
117
141
|
if (isFunction(watcher.unref)) watcher.unref();
|
|
118
142
|
ok = true;
|
|
119
143
|
} catch {
|
|
120
144
|
ok = false;
|
|
121
145
|
}
|
|
146
|
+
|
|
122
147
|
this.watchers.set(root, ok);
|
|
148
|
+
|
|
123
149
|
return ok;
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
/** Paths git reports as modified/added/untracked (fff's git-status boost); one spawn per list refresh. */
|
|
127
153
|
async modifiedFiles(root) {
|
|
128
154
|
const cached = this.gitModified.get(root);
|
|
155
|
+
|
|
129
156
|
if (cached) return cached;
|
|
130
157
|
const set = new Set();
|
|
158
|
+
|
|
131
159
|
try {
|
|
132
160
|
const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
|
|
161
|
+
|
|
133
162
|
if (res.exitCode === 0) {
|
|
134
163
|
for (const row of res.stdout.split("\0")) {
|
|
135
164
|
if (row.length > 3) set.add(row.slice(3));
|
|
136
165
|
}
|
|
137
166
|
}
|
|
138
167
|
} catch {}
|
|
168
|
+
|
|
139
169
|
this.gitModified.set(root, set);
|
|
170
|
+
|
|
140
171
|
return set;
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
mtimeSeconds(filePath) {
|
|
144
175
|
const e = this.entries.get(filePath);
|
|
176
|
+
|
|
145
177
|
if (e) return e.mtimeMs / 1000;
|
|
178
|
+
|
|
146
179
|
try {
|
|
147
180
|
return fs.statSync(filePath).mtimeMs / 1000;
|
|
148
181
|
} catch {
|
|
@@ -155,16 +188,20 @@ export class WorkspaceIndex {
|
|
|
155
188
|
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
156
189
|
const cached = this.lists.get(key);
|
|
157
190
|
const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
|
|
191
|
+
|
|
158
192
|
if (cached && Date.now() - cached.at < ttl) return cached.files;
|
|
159
193
|
const args = ["rg", "--files"];
|
|
194
|
+
|
|
160
195
|
if (includeHidden) args.push("--hidden");
|
|
161
196
|
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
162
197
|
let files = [];
|
|
163
198
|
let error;
|
|
164
199
|
let truncated = false;
|
|
165
200
|
let missing = false;
|
|
201
|
+
|
|
166
202
|
try {
|
|
167
203
|
const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000, signal });
|
|
204
|
+
|
|
168
205
|
if (res.exitCode !== 0 && res.exitCode !== 1) error = res.stderr.trim() || "rg exited with status " + res.exitCode;
|
|
169
206
|
truncated = res.outputTruncated === true;
|
|
170
207
|
const output = truncated && !res.stdout.endsWith("\n") ? res.stdout.slice(0, res.stdout.lastIndexOf("\n") + 1) : res.stdout;
|
|
@@ -174,7 +211,9 @@ export class WorkspaceIndex {
|
|
|
174
211
|
error = err.message;
|
|
175
212
|
missing = !fs.existsSync(root);
|
|
176
213
|
}
|
|
214
|
+
|
|
177
215
|
this.lists.set(key, { files, at: Date.now(), error, truncated, missing });
|
|
216
|
+
|
|
178
217
|
return files;
|
|
179
218
|
}
|
|
180
219
|
|
|
@@ -182,24 +221,31 @@ export class WorkspaceIndex {
|
|
|
182
221
|
entry(filePath) {
|
|
183
222
|
if (!isTextCandidate(filePath)) return null;
|
|
184
223
|
let stat;
|
|
224
|
+
|
|
185
225
|
try {
|
|
186
226
|
stat = fs.statSync(filePath);
|
|
187
227
|
} catch {
|
|
188
228
|
this.entries.delete(filePath);
|
|
229
|
+
|
|
189
230
|
return null;
|
|
190
231
|
}
|
|
232
|
+
|
|
191
233
|
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
|
|
192
234
|
const cached = this.entries.get(filePath);
|
|
235
|
+
|
|
193
236
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
|
|
194
237
|
let text;
|
|
238
|
+
|
|
195
239
|
try {
|
|
196
240
|
text = fs.readFileSync(filePath, "utf8");
|
|
197
241
|
} catch {
|
|
198
242
|
return null;
|
|
199
243
|
}
|
|
244
|
+
|
|
200
245
|
if (text.includes("\0")) return null;
|
|
201
246
|
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
202
247
|
this.entries.set(filePath, created);
|
|
248
|
+
|
|
203
249
|
return created;
|
|
204
250
|
}
|
|
205
251
|
|
|
@@ -214,13 +260,16 @@ export class WorkspaceIndex {
|
|
|
214
260
|
const lower = [];
|
|
215
261
|
const defNames = [];
|
|
216
262
|
const idents = [];
|
|
263
|
+
|
|
217
264
|
for (let i = 0; i < raw.length; i++) {
|
|
218
265
|
const trimmed = raw[i].trim();
|
|
219
266
|
lower[i] = trimmed.toLowerCase();
|
|
220
267
|
defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
|
|
221
268
|
idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
|
|
222
269
|
}
|
|
270
|
+
|
|
223
271
|
entry.lines = { raw, lower, defNames, idents };
|
|
272
|
+
|
|
224
273
|
return entry.lines;
|
|
225
274
|
}
|
|
226
275
|
|
|
@@ -233,18 +282,23 @@ export class WorkspaceIndex {
|
|
|
233
282
|
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
234
283
|
const { lower } = WorkspaceIndex.linesOf(entry);
|
|
235
284
|
const spans = [];
|
|
285
|
+
|
|
236
286
|
for (let i = 0; i < items.length; i++) {
|
|
237
287
|
const start = items[i].line;
|
|
238
288
|
let end = Math.min(i + 1 < items.length ? items[i + 1].line - 1 : lineCount, lineCount);
|
|
289
|
+
|
|
239
290
|
while (end > start && lower[end - 1] === "") end--;
|
|
240
291
|
spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
|
|
241
292
|
}
|
|
293
|
+
|
|
242
294
|
entry.spans = spans;
|
|
295
|
+
|
|
243
296
|
return spans;
|
|
244
297
|
}
|
|
245
298
|
|
|
246
299
|
static surfaceOf(entry) {
|
|
247
300
|
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
301
|
+
|
|
248
302
|
return entry.surface;
|
|
249
303
|
}
|
|
250
304
|
|
|
@@ -256,12 +310,16 @@ export class WorkspaceIndex {
|
|
|
256
310
|
/** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
|
|
257
311
|
filesContaining(files, needles, anyOf) {
|
|
258
312
|
const hits = [];
|
|
313
|
+
|
|
259
314
|
for (const filePath of files) {
|
|
260
315
|
const e = this.entry(filePath);
|
|
316
|
+
|
|
261
317
|
if (!e) continue;
|
|
262
318
|
const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
|
|
319
|
+
|
|
263
320
|
if (found) hits.push(filePath);
|
|
264
321
|
}
|
|
322
|
+
|
|
265
323
|
return hits;
|
|
266
324
|
}
|
|
267
325
|
|
|
@@ -269,16 +327,20 @@ export class WorkspaceIndex {
|
|
|
269
327
|
grepRows(files, regex, root, overlayText = () => undefined) {
|
|
270
328
|
const out = [];
|
|
271
329
|
const nameRegex = new RegExp(regex.source, "i");
|
|
330
|
+
|
|
272
331
|
for (const filePath of files) {
|
|
273
332
|
const pending = overlayText(filePath);
|
|
274
333
|
const e = pending === undefined ? this.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
|
|
334
|
+
|
|
275
335
|
if (!e || !regex.test(e.text)) continue;
|
|
276
336
|
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
277
337
|
const rel = relativeSlash(root, filePath);
|
|
338
|
+
|
|
278
339
|
for (let i = 0; i < raw.length; i++) {
|
|
279
340
|
if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
|
|
280
341
|
}
|
|
281
342
|
}
|
|
343
|
+
|
|
282
344
|
return out;
|
|
283
345
|
}
|
|
284
346
|
}
|