pi-supernova 0.9.0 → 0.10.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,3 +1,4 @@
1
+ import { createBackgroundTerminals } from "../fs/background.js";
1
2
  import {createToolRegistry} from './tool-registry.js';
2
3
  import {traceArgs,finishRecord} from './trace.js';
3
4
  import * as fs from "node:fs/promises";
@@ -16,20 +17,23 @@ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from
16
17
  import { createNativeAdapters } from "../adapters/index.js";
17
18
  import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
18
19
 
19
- export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
20
+ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget, terminalIdentity }) {
20
21
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
21
22
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
22
23
 
23
24
  const vfs = new CausalVfs(paths => {
24
25
  index.invalidate();
25
26
  notifyWorkspaceChanged(paths);
26
- }, target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
27
+ }, target => resolveWorkspacePath(getCwd(), target, "commit", false, true), assertSession);
27
28
 
28
29
  const executors = registry?.executors ?? new Map();
29
30
  const definitions = registry?.definitions ?? new Map();
30
31
  const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
32
+ sharedRegistry.terminals ??= createBackgroundTerminals();
31
33
  let closed = false;
32
- const hooks = {};
34
+ const hooks = { terminals: sharedRegistry.terminals, terminalGeneration: terminalIdentity?.generation ?? sharedRegistry.terminals.getGeneration() };
35
+ const ownerOf = ctx => JSON.stringify([ctx?.sessionManager?.getSessionId?.() ?? null, path.resolve(getCwd())]);
36
+ let terminalOwner = terminalIdentity?.owner ?? ownerOf(null);
33
37
  const natives = createNativeAdapters(getCwd, vfs, config, index, ledger, hooks);
34
38
  let callCount = 0;
35
39
  let activeCtx = null;
@@ -51,9 +55,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
51
55
  try { pi.events.emit("workspace:changed", event)?.catch?.(() => {}); } catch {}
52
56
  }
53
57
 
58
+ hooks.terminalOwner = () => terminalOwner;
54
59
  hooks.workspaceChanged = notifyWorkspaceChanged;
55
- hooks.artifactsDir = () => activeCtx?.sessionManager?.getArtifactsDir?.();
60
+ hooks.artifactsDir = () => {
61
+ assertSession();
62
+
63
+ return activeCtx?.sessionManager?.getArtifactsDir?.();
64
+ };
65
+
56
66
  hooks.commandEnv = () => {
67
+ assertSession();
57
68
  const env = { ...process.env };
58
69
 
59
70
  const current = {
@@ -77,6 +88,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
77
88
 
78
89
  function bindCallContext(ctx, signal) {
79
90
  activeCtx = ctx || null;
91
+ terminalOwner = terminalIdentity?.owner ?? ownerOf(ctx);
80
92
  tools.bindSession(ctx);
81
93
  activeSignal = signal;
82
94
  vfs.signal = signal;
@@ -100,8 +112,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
100
112
  } catch {}
101
113
  }
102
114
 
115
+ function assertSession() {
116
+ if (hooks.terminalGeneration !== sharedRegistry.terminals.getGeneration() || terminalOwner !== ownerOf(activeCtx)) {
117
+ throw new Error("host session changed; start a new call");
118
+ }
119
+ }
120
+
103
121
  function assertRunOpen(name) {
104
122
  if (closed) throw new Error("program is already complete");
123
+ assertSession();
105
124
 
106
125
  if (activeSignal?.aborted) throw new Error("aborted");
107
126
 
@@ -149,7 +168,10 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
149
168
  }
150
169
 
151
170
  function assertOwnedOverride(name, args) {
171
+ if (name === "bash" && (args?.background === true || args?.action !== undefined)) throw new Error("background terminals require the Supernova-owned bash adapter, not an external override");
172
+
152
173
  if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
174
+
153
175
  if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
154
176
  }
155
177
 
@@ -159,8 +181,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
159
181
  const mutating = isMutatingTool(name, config, args, definitions.get(name));
160
182
 
161
183
  if (mutating) await vfs.prepareExternalMutation(name);
184
+
162
185
  if (activeSignal?.aborted || closed) throw new Error("aborted");
186
+
163
187
  if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
188
+ assertSession();
164
189
 
165
190
  try {
166
191
  const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
@@ -176,6 +201,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
176
201
  }
177
202
 
178
203
  async function invokeNative(target, args, record, onItem) {
204
+ assertSession();
179
205
  const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal, onItem);
180
206
  completeRecord(record, res);
181
207
 
@@ -207,6 +233,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
207
233
  const target = resolveInvokeTarget(name, args, { hostTool, hostSession: tools.session, executors, natives });
208
234
 
209
235
  if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
236
+
210
237
  if (target.kind === "native") return await invokeNative(target, args, record, onItem);
211
238
 
212
239
  throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
@@ -218,11 +245,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
218
245
 
219
246
  function fileMutationKey(name, args) {
220
247
  if (!["edit", "write", "apply_patch"].includes(name) || !isString(args?.path)) return;
248
+
221
249
  // Overrides can mutate more than their declared path: keep them global.
222
250
  if (hostTool(name) || executors.has(name)) return;
223
251
 
224
252
  return async () => {
225
253
  const target = await resolveWorkspacePath(getCwd(), args.path, name, false, true);
254
+
226
255
  // Share the commit identity, including symlinks and not-yet-created files.
227
256
  return (await resolveCommitTarget(target)).target;
228
257
  };
@@ -296,8 +325,14 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
296
325
  fork(options) {
297
326
  const runConfig = options.timeoutMs === undefined ? config : { ...config, timeoutMs: Number(options.timeoutMs) };
298
327
 
299
- return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
328
+ return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget, terminalIdentity:options.terminalIdentity });
300
329
  },
330
+ captureTerminalIdentity: (ctx, runCwd) => ({
331
+ generation:sharedRegistry.terminals.getGeneration(),
332
+ owner:JSON.stringify([ctx?.sessionManager?.getSessionId?.() ?? null, path.resolve(runCwd)]),
333
+ }),
334
+ shutdownTerminals: () => sharedRegistry.terminals.shutdown(),
335
+ reopenTerminals: () => sharedRegistry.terminals.reopen(),
301
336
  close() { closed = true; vfs.closed = true; },
302
337
  bindCallContext,
303
338
  resetCallBudget,
@@ -305,8 +340,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
305
340
  getMutations: () => ({ ...vfs.mutations }),
306
341
  setCallListener: fn => { callListener = isFunction(fn) ? fn : null; },
307
342
  barrier: run => scheduler.schedule("write", run, activeSignal),
308
- beginSpeculation: () => vfs.begin(),
309
- commitSpeculation: async () => await vfs.commit(),
343
+ beginSpeculation() {
344
+ assertSession();
345
+
346
+ return vfs.begin();
347
+ },
348
+ async commitSpeculation() {
349
+ assertSession();
350
+
351
+ return await vfs.commit();
352
+ },
310
353
  rollbackSpeculation: () => vfs.rollback(),
311
354
  getOverlayDepth: () => vfs.getOverlayDepth(),
312
355
  call,
@@ -3,15 +3,18 @@ import { isString } from "../shared/decode.js";
3
3
  /** Ordered permission rules. First decisive answer wins. */
4
4
  export function toolIsCallable(name, env) {
5
5
  if (name === "supernova" || env.excluded.has(name)) return false;
6
+
6
7
  if (env.sessionInvalid()) return false;
8
+
7
9
  if (env.nativeOwned(name)) return true;
10
+
8
11
  if (env.hostSession) return env.evalAllows(name);
9
12
 
10
13
  return env.listed(name);
11
14
  }
12
15
 
13
16
  function isArgvOwned(name, args) {
14
- return name === "bash" && Array.isArray(args?.args) && process.platform !== "win32"
17
+ return name === "bash" && args?.background !== true && args?.action === undefined && Array.isArray(args?.args)
15
18
  && args.args.length === Object.keys(args.args).length && args.args.every(isString);
16
19
  }
17
20
 
@@ -19,6 +22,7 @@ function hostExecutor(name, env) {
19
22
  const delegated = env.hostTool(name);
20
23
 
21
24
  if (delegated) return { exec: delegated.execute.bind(delegated), delegated };
25
+
22
26
  if (env.hostSession) return { exec: undefined, delegated };
23
27
 
24
28
  return { exec: env.executors.get(name), delegated };
@@ -29,6 +33,7 @@ export function resolveInvokeTarget(name, args, env) {
29
33
  const { exec, delegated } = hostExecutor(name, env);
30
34
 
31
35
  if (exec && !argvOwned) return { kind: "override", exec, delegated, argvOwned: false };
36
+
32
37
  if (env.natives[name]) return { kind: "native", native: env.natives[name], argvOwned };
33
38
 
34
39
  return { kind: "unknown" };
@@ -7,7 +7,7 @@ function traceArgs(args) {
7
7
  if (!isObject(args)) return {};
8
8
  const out = {};
9
9
 
10
- for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
10
+ for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "sessionId", "op"]) {
11
11
  const value = args[key];
12
12
 
13
13
  if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
@@ -16,7 +16,9 @@ function traceArgs(args) {
16
16
  }
17
17
 
18
18
  if (isString(args.content)) out.content = args.content.length + " chars";
19
+
19
20
  if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
21
+
20
22
  if (Array.isArray(args.args)) out.args = args.args.length + " argv";
21
23
 
22
24
  return out;
@@ -38,4 +40,5 @@ function finishRecord(record, res) {
38
40
 
39
41
  if (text) record.resultText = truncateChars(text, 4096, "trace").text;
40
42
  }
43
+
41
44
  export { traceArgs, finishRecord };
@@ -3,6 +3,7 @@ import {pendingInScope,overlaySearchEntry} from './search-files.js';
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { WorkspaceIndex } from "./repo-index.js";
6
+ import { relativeSlash } from "../fs/workspace.js";
6
7
  import { tokenizeQuery, scorePathTopology, stem } from "./query.js";
7
8
 
8
9
  // Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
@@ -148,15 +149,18 @@ function render(spans, picks, fused, opts, root) {
148
149
  const lastLine = span.start + text.split("\n").length - 1;
149
150
  const truncated = lastLine < span.sourceEnd;
150
151
  budget -= text.length;
152
+
151
153
  const rendered = {
152
- path: path.relative(root, span.path) || span.path,
154
+ path: relativeSlash(root, span.path),
153
155
  lines: [span.start, lastLine],
154
156
  name: span.name,
155
157
  kind: span.kind,
156
158
  why,
157
159
  text,
158
160
  };
161
+
159
162
  if (truncated) { rendered.truncated = true; rendered.nextOffset = lastLine+1; }
163
+
160
164
  out.push(rendered);
161
165
 
162
166
  if (budget <= 0) break;
@@ -206,15 +210,20 @@ function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
206
210
  // its declaration. Keep the matching line in the bounded window, even in long bodies.
207
211
  function usageSpans(spans, profile, maxSpanLines) {
208
212
  if (profile.answerType !== "usage" || !profile.subjects.length) return spans;
213
+
209
214
  return spans.flatMap(span => {
210
215
  for (let i = span.start - 1; i < span.sourceEnd; i++) {
211
216
  const words = span.lines.idents[i];
217
+
212
218
  const matched = profile.subjects.some(subject =>
213
219
  words.filter(word => word === subject).length > Number(span.lines.defNames[i] === subject.toLowerCase()));
220
+
214
221
  if (!matched) continue;
215
222
  const start = Math.max(span.start, i - 1);
223
+
216
224
  return [{ ...span, start, end: Math.min(span.sourceEnd, start + maxSpanLines - 1) }];
217
225
  }
226
+
218
227
  return [];
219
228
  });
220
229
  }
@@ -40,10 +40,13 @@ export class Frecency {
40
40
  score(filePath, mtimeSec, now = Date.now() / 1000) {
41
41
  let total = 0;
42
42
  const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
43
+ const stamps = this.access.get(filePath);
43
44
 
44
- for (const t of this.access.get(filePath) || []) {
45
- if (t < cutoff) continue;
46
- total += Math.exp(-AI_DECAY * ((now - t) / 86400));
45
+ if (stamps) {
46
+ for (const t of stamps) {
47
+ if (t < cutoff) continue;
48
+ total += Math.exp(-AI_DECAY * ((now - t) / 86400));
49
+ }
47
50
  }
48
51
 
49
52
  if (mtimeSec) {
@@ -74,33 +77,42 @@ function isBoundary(hay, i) {
74
77
  }
75
78
 
76
79
  /**
77
- * Greedy forward match with backward tightening (fzf v1). Returns null or
78
- * { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
80
+ * Greedy forward scan. Returns the match end or the needle index that failed
81
+ * (failAt), which the typo retry uses to prune deletions provably unable to
82
+ * match (see matchWithTypos).
79
83
  */
80
- function matchOnce(needle, hay, caseSensitive) {
81
- const hayCmp = caseSensitive ? hay : hay.toLowerCase();
82
- const nCmp = caseSensitive ? needle : needle.toLowerCase();
84
+ function scanForward(nCmp, hayCmp) {
83
85
  let hi = 0;
84
- let firstAt = -1;
85
86
 
86
87
  for (let ni = 0; ni < nCmp.length; ni++) {
87
88
  hi = hayCmp.indexOf(nCmp[ni], hi);
88
89
 
89
- if (hi < 0) return null;
90
-
91
- if (firstAt < 0) firstAt = hi;
90
+ if (hi < 0) return { failAt: ni };
92
91
  hi++;
93
92
  }
94
93
 
95
- const end = hi;
94
+ return { end: hi, failAt: -1 };
95
+ }
96
+
97
+ /**
98
+ * Greedy forward match with backward tightening (fzf v1). Returns null or
99
+ * { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
100
+ * Lowered strings arrive precomputed: the needle once per query, the haystack
101
+ * once per path — never re-lowered per part or per typo variant.
102
+ */
103
+ function matchOnce(part, pCmp, hay, hayCmp) {
104
+ const scan = scanForward(pCmp, hayCmp);
105
+
106
+ if (scan.failAt >= 0) return null;
107
+ const end = scan.end;
96
108
  // Tighten: walk backwards from end to find the latest possible start.
97
109
  let start = end;
98
110
 
99
- for (let ni = nCmp.length - 1; ni >= 0; ni--) {
100
- start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
111
+ for (let ni = pCmp.length - 1; ni >= 0; ni--) {
112
+ start = hayCmp.lastIndexOf(pCmp[ni], start - 1);
101
113
  }
102
114
 
103
- return { score: scoreAlignment(needle, nCmp, hay, hayCmp, start), start, end };
115
+ return { score: scoreAlignment(part, pCmp, hay, hayCmp, start), start, end };
104
116
  }
105
117
 
106
118
  /** +16 boundary, +8 consecutive, +4 exact-case, −1 per skipped haystack char. */
@@ -122,9 +134,14 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
122
134
  return score;
123
135
  }
124
136
 
125
- function considerShorter(part, typosLeft, visit, best) {
126
- for (let i = 0; i < part.length; i++) {
127
- const m = visit(part.slice(0, i) + part.slice(i + 1), typosLeft - 1);
137
+ function considerShorter(sub, subCmp, subLower, typosLeft, visit, best, maxDel) {
138
+ for (let i = 0; i <= maxDel; i++) {
139
+ const m = visit(
140
+ sub.slice(0, i) + sub.slice(i + 1),
141
+ subCmp.slice(0, i) + subCmp.slice(i + 1),
142
+ subLower.slice(0, i) + subLower.slice(i + 1),
143
+ typosLeft - 1,
144
+ );
128
145
 
129
146
  if (!m) continue;
130
147
  const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
@@ -135,46 +152,95 @@ function considerShorter(part, typosLeft, visit, best) {
135
152
  return best;
136
153
  }
137
154
 
138
- function matchWithTypos(needle, hay, maxTypos, caseSensitive) {
155
+ // Failure pruning (exact, not heuristic): a deletion strictly after the
156
+ // fail index preserves the failing prefix, so that child fails too — and
157
+ // every deeper success deletes an early char first, which the unpruned
158
+ // order reaches with the same typo count via memo. On success all
159
+ // deletions are still explored (a shorter variant can outscore the -12).
160
+ // This turns full-miss retries from O(len^typos) attempts into O(len×typos).
161
+ function matchWithTypos(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos) {
139
162
  const memo = new Map();
163
+ let hayLower = hayLowerOrNull;
140
164
 
141
- const visit = (part, typosLeft) => {
142
- const key = part + "\0" + typosLeft;
165
+ const visit = (sub, subCmp, subLower, typosLeft) => {
166
+ const key = sub + "\0" + typosLeft;
143
167
 
144
168
  if (memo.has(key)) return memo.get(key);
145
- let best = matchOnce(part, hay, caseSensitive);
169
+ const scan = scanForward(subCmp, hayCmp);
170
+ let best = null;
171
+ let maxDel = sub.length - 1;
172
+
173
+ if (scan.failAt < 0) {
174
+ let start = scan.end;
175
+
176
+ for (let ni = subCmp.length - 1; ni >= 0; ni--) {
177
+ start = hayCmp.lastIndexOf(subCmp[ni], start - 1);
178
+ }
146
179
 
147
- if (best) best = { ...best, typos: 0, exact: hay.toLowerCase() === part.toLowerCase() };
180
+ if (hayLower === null) hayLower = hay.toLowerCase();
181
+ best = {
182
+ score: scoreAlignment(sub, subCmp, hay, hayCmp, start),
183
+ start,
184
+ end: scan.end,
185
+ typos: 0,
186
+ exact: hayLower === subLower,
187
+ };
188
+ } else {
189
+ maxDel = scan.failAt;
190
+ }
148
191
 
149
- if (typosLeft > 0) best = considerShorter(part, typosLeft, visit, best);
192
+ if (typosLeft > 0) best = considerShorter(sub, subCmp, subLower, typosLeft, visit, best, maxDel);
150
193
  memo.set(key, best);
151
194
 
152
195
  return best;
153
196
  };
154
197
 
155
- return visit(needle, maxTypos);
198
+ return visit(part, pCmp, partLower, maxTypos);
199
+ }
200
+
201
+ function matchPart(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos) {
202
+ const direct = matchOnce(part, pCmp, hay, hayCmp);
203
+
204
+ if (direct) {
205
+ const hayLower = hayLowerOrNull === null ? hay.toLowerCase() : hayLowerOrNull;
206
+
207
+ return { ...direct, typos: 0, exact: hayLower === partLower };
208
+ }
209
+
210
+ if (maxTypos <= 0 || part.length < 3 || part.length > 128) return null;
211
+
212
+ return matchWithTypos(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos);
156
213
  }
157
214
 
158
215
  /** Best match allowing up to maxTypos skipped needle characters. */
159
216
  export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
160
- const direct = matchOnce(needle, hay, caseSensitive);
161
-
162
- if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
217
+ if (caseSensitive) return matchPart(needle, needle, needle.toLowerCase(), hay, hay, null, maxTypos);
163
218
 
164
- if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
219
+ const needleLower = needle.toLowerCase();
220
+ const hayLower = hay.toLowerCase();
165
221
 
166
- return matchWithTypos(needle, hay, maxTypos, caseSensitive);
222
+ return matchPart(needle, needleLower, needleLower, hay, hayLower, hayLower, maxTypos);
167
223
  }
168
224
 
169
225
  export function smartCase(query) {
170
226
  return /[A-Z]/.test(query);
171
227
  }
172
228
 
229
+ function splitDirSegs(dir) {
230
+ return dir.split("/").filter(Boolean);
231
+ }
232
+
173
233
  /** fff distance penalty: directory hops from the current file's directory, floor −20. */
174
- function distancePenalty(currentDir, candidateDir) {
175
- if (!currentDir) return 0;
176
- const a = currentDir.split("/").filter(Boolean);
177
- const b = candidateDir.split("/").filter(Boolean);
234
+ function distancePenalty(currentSegs, candidateDir, dirCache) {
235
+ if (!currentSegs) return 0;
236
+ let b = dirCache.get(candidateDir);
237
+
238
+ if (!b) {
239
+ b = splitDirSegs(candidateDir);
240
+ dirCache.set(candidateDir, b);
241
+ }
242
+
243
+ const a = currentSegs;
178
244
  let common = 0;
179
245
 
180
246
  while (common < a.length && common < b.length && a[common] === b[common]) common++;
@@ -191,13 +257,14 @@ function partTypos(parts, ctx) {
191
257
  return ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
192
258
  }
193
259
 
194
- function scoredPath(rel, parts, maxTypos, caseSensitive, ctx, currentDir) {
195
- const matched = matchParts(parts, rel, maxTypos, caseSensitive);
260
+ function scoredPath(rel, parts, partLower, maxTypos, caseSensitive, ctx, currentSegs, dirCache) {
261
+ const hayCmp = caseSensitive ? rel : rel.toLowerCase();
262
+ const matched = matchParts(parts, partLower, rel, hayCmp, caseSensitive ? null : hayCmp, maxTypos, caseSensitive);
196
263
 
197
264
  if (!matched) return null;
198
265
  const { base, first, exact } = matched;
199
266
  const filenameStart = rel.lastIndexOf("/") + 1;
200
- const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
267
+ const boosts = filenameBonus(base, rel, filenameStart, first, partLower[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentSegs, rel.slice(0, filenameStart), dirCache);
201
268
 
202
269
  return { path: rel, score: base + boosts, exact, typos: first.typos };
203
270
  }
@@ -208,11 +275,17 @@ export function rankPaths(query, paths, ctx = {}) {
208
275
  if (parts.length === 0 || parts.length > 16) return [];
209
276
  const caseSensitive = smartCase(query);
210
277
  const maxTypos = partTypos(parts, ctx);
278
+ // Per-query hoists: lowered parts once (not once per path per part), the
279
+ // current directory split once (not once per candidate), plus a
280
+ // per-call cache for candidate directory segments (paths share dirs).
281
+ const partLower = parts.map((p) => p.toLowerCase());
211
282
  const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
283
+ const currentSegs = currentDir ? splitDirSegs(currentDir) : null;
284
+ const dirCache = new Map();
212
285
  const out = [];
213
286
 
214
287
  for (const rel of paths) {
215
- const scored = scoredPath(rel, parts, maxTypos, caseSensitive, ctx, currentDir);
288
+ const scored = scoredPath(rel, parts, partLower, maxTypos, caseSensitive, ctx, currentSegs, dirCache);
216
289
 
217
290
  if (scored) out.push(scored);
218
291
  }
@@ -223,13 +296,13 @@ export function rankPaths(query, paths, ctx = {}) {
223
296
  }
224
297
 
225
298
  /** Every query part must match; later parts get at most one typo (fff narrows per part). Score is the average. */
226
- function matchParts(parts, rel, maxTypos, caseSensitive) {
299
+ function matchParts(parts, partLower, rel, hayCmp, hayLowerOrNull, maxTypos, caseSensitive) {
227
300
  let sum = 0;
228
301
  let first = null;
229
302
  let exact = true;
230
303
 
231
304
  for (let pi = 0; pi < parts.length; pi++) {
232
- const m = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
305
+ const m = matchPart(parts[pi], caseSensitive ? parts[pi] : partLower[pi], partLower[pi], rel, hayCmp, hayLowerOrNull, pi === 0 ? maxTypos : Math.min(maxTypos, 1));
233
306
 
234
307
  if (!m) return null;
235
308
  first ??= m;
@@ -241,10 +314,10 @@ function matchParts(parts, rel, maxTypos, caseSensitive) {
241
314
  }
242
315
 
243
316
  /** fff: exact filename +40% of base, any filename match +20%. */
244
- function filenameBonus(base, rel, filenameStart, first, needle) {
317
+ function filenameBonus(base, rel, filenameStart, first, needleLower) {
245
318
  if (first.start < filenameStart) return 0;
246
319
 
247
- return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
320
+ return rel.slice(filenameStart).toLowerCase() === needleLower ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
248
321
  }
249
322
 
250
323
  /** fff: frecency boost base·f/100 and +15% for git-modified files. */
@@ -12,6 +12,14 @@ const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs",
12
12
 
13
13
  const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
14
14
 
15
+ const BUILD_DIRS = new Set(["node_modules", "dist", "target"]);
16
+
17
+ const TEST_WORDS = new Set(["test", "tests", "testing", "spec", "specs"]);
18
+
19
+ const TYPE_WORDS = new Set(["type", "types", "interface", "interfaces", "schema", "schemas"]);
20
+
21
+ const DOC_WORDS = new Set(["doc", "docs", "documentation", "readme"]);
22
+
15
23
  const MAX_NEEDLE_CHARS = 128;
16
24
 
17
25
  /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
@@ -27,9 +35,9 @@ export function tokenizeQuery(query) {
27
35
 
28
36
  return {
29
37
  tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
30
- wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
31
- wantsType: words.some(word => ["type", "types", "interface", "interfaces", "schema", "schemas"].includes(word)),
32
- wantsDoc: words.some(word => ["doc", "docs", "documentation", "readme"].includes(word)),
38
+ wantsTest: words.some(word => TEST_WORDS.has(word)),
39
+ wantsType: words.some(word => TYPE_WORDS.has(word)),
40
+ wantsDoc: words.some(word => DOC_WORDS.has(word)),
33
41
  };
34
42
  }
35
43
 
@@ -37,7 +45,8 @@ function tokenPathScore(base, words, normalized, tokens) {
37
45
  let score = 0;
38
46
 
39
47
  for (const token of tokens) {
40
- if (base === token || base.startsWith(token + ".")) score += 60;
48
+ // base === token + "." without the concat alloc: same verdict, no garbage.
49
+ if (base === token || (base.length > token.length && base[token.length] === "." && base.startsWith(token))) score += 60;
41
50
  else if (base.includes(token)) score += 30;
42
51
  else if (words.includes(token)) score += 15;
43
52
  else if (normalized.includes(token)) score += 5;
@@ -49,7 +58,7 @@ function tokenPathScore(base, words, normalized, tokens) {
49
58
  function topologyPenalty(normalized, flags) {
50
59
  const parts = normalized.split("/");
51
60
 
52
- if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
61
+ if (parts.some(part => BUILD_DIRS.has(part))) return -100;
53
62
  const test = isTestPath(normalized);
54
63
 
55
64
  if (test && !flags.wantsTest) return -50;
@@ -113,6 +113,19 @@ function grepEntryRows(e, filePath, root, regex, nameRegex, out) {
113
113
  }
114
114
  }
115
115
 
116
+ // One alternation scan per file instead of one full scan per needle — same
117
+ // verdict as needles.some(includes). Needles are escaped: they arrive as
118
+ // literals that may carry regex syntax.
119
+ function anyOfProbe(needles) {
120
+ return new RegExp(needles.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"));
121
+ }
122
+
123
+ function fileMatchesNeedles(entry, needles, anyOf, probe) {
124
+ if (probe) return probe.test(entry.lower);
125
+
126
+ return anyOf ? needles.some((n) => entry.lower.includes(n)) : needles.every((n) => entry.lower.includes(n));
127
+ }
128
+
116
129
  export class WorkspaceIndex {
117
130
  constructor(runCommand) {
118
131
  this.runCommand = runCommand;
@@ -358,14 +371,12 @@ export class WorkspaceIndex {
358
371
  /** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
359
372
  filesContaining(files, needles, anyOf) {
360
373
  const hits = [];
374
+ const probe = anyOf && needles.length > 1 ? anyOfProbe(needles) : null;
361
375
 
362
376
  for (const filePath of files) {
363
377
  const e = this.entry(filePath);
364
378
 
365
- if (!e) continue;
366
- const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
367
-
368
- if (found) hits.push(filePath);
379
+ if (e && fileMatchesNeedles(e, needles, anyOf, probe)) hits.push(filePath);
369
380
  }
370
381
 
371
382
  return hits;