pi-supernova 0.3.1 → 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.
@@ -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
- const DEFAULT_WINDOW = 40;
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])) 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
- for (let i = index; i < index + best.length; i++) if (substantive(lines[i])) count++;
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 = substantive(lines[i]) ? this.longestRun(lines, hashes, i, call) : null;
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 (!substantive(lines[i])) continue;
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
  }
@@ -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,30 +34,64 @@ 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
 
68
+ function focusedText(raw, lower, stems, relPath, opts) {
69
+ const hits = lower.map((line, i) => ({i, score: stems.filter(s => line.includes(s)).length}))
70
+ .filter(hit => hit.score > 0).sort((a, b) => b.score - a.score || a.i - b.i);
71
+
72
+ const parts = [];
73
+ const covered = new Set();
74
+ let budget = opts.maxChars;
75
+
76
+ for (const {i} of hits) {
77
+ if (parts.length >= opts.maxExpanded) break;
78
+
79
+ if (covered.has(i)) continue;
80
+ const start = Math.max(0, i - 3), end = Math.min(raw.length, i + 4);
81
+ const block = raw.slice(start, end).map((line, j) => String(start + j + 1).padStart(5) + " " + line).join("\n");
82
+
83
+ if (block.length > budget) continue;
84
+ parts.push(block); budget -= block.length;
85
+
86
+ for (let j = start; j < end; j++) covered.add(j);
87
+ }
88
+
89
+ const status = parts.length ? "focused text windows (not a complete file)"
90
+ : hits.length ? "matching text exceeds view budget; first match at line " + (hits[0].i + 1) : "no matching text";
91
+
92
+ return {text: "// " + relPath + " · " + status + "; read(path, line, count) for raw source\n" + parts.join("\n---\n"), expanded: parts.length, declarations: 0};
93
+ }
94
+
57
95
  /**
58
96
  * @param entry index entry (text + cached lines/surface)
59
97
  * @param about question or symbol; empty ⇒ pure skeleton (every body folded)
@@ -63,18 +101,24 @@ export function outlineFile(entry, relPath, about, options = {}) {
63
101
  const { raw, lower } = WorkspaceIndex.linesOf(entry);
64
102
  const lineCount = raw.length;
65
103
  const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
66
- if (spans.length === 0) return null; // no structure: caller falls back to plain text
67
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;
68
107
  const expanded = chooseExpanded(spans, lower, stems, raw, opts);
69
108
 
70
109
  const parts = [];
71
110
  const headerEnd = Math.min(spans[0].start - 1, opts.headerLines);
111
+
72
112
  if (headerEnd > 0) {
73
113
  const header = raw.slice(0, headerEnd);
114
+
74
115
  if (header.length) parts.push(header.map((l, i) => String(i + 1).padStart(5) + " " + l).join("\n"));
116
+
75
117
  if (spans[0].start - 1 > opts.headerLines) parts.push(" … " + (spans[0].start - 1 - opts.headerLines) + " more header lines");
76
118
  }
119
+
77
120
  for (let i = 0; i < spans.length; i++) parts.push(expanded.has(i) ? expandedBlock(spans[i], raw, opts) : foldedLine(spans[i]));
78
121
  const title = "// " + relPath + " · " + lineCount + " lines · " + spans.length + " declarations · " + expanded.size + " expanded" + (about ? " for \"" + about + "\"" : "") + " · read(path, line, count) for a folded body";
122
+
79
123
  return { text: title + "\n" + parts.join("\n"), expanded: expanded.size, declarations: spans.length };
80
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,9 +120,11 @@ 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;
105
- const watcher = fs.watch(root, { recursive: true }, () => {
126
+
127
+ const watcher = fs.watch(root, { recursive: true, persistent: false }, () => {
106
128
  if (timer) return;
107
129
  timer = setTimeout(() => {
108
130
  timer = null;
@@ -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
  }