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.
- package/README.md +191 -14
- package/index.js +30 -9
- package/package.json +1 -1
- package/src/adapters/bash.js +40 -0
- package/src/adapters/read.js +24 -3
- package/src/bridge/host-bridge.js +50 -7
- package/src/bridge/invoke.js +6 -1
- package/src/bridge/trace.js +4 -1
- package/src/context/evidence.js +10 -1
- package/src/context/fuzzy.js +116 -43
- package/src/context/query.js +14 -5
- package/src/context/repo-index.js +15 -4
- package/src/context/snap-search.js +14 -13
- package/src/context/snap.js +11 -2
- package/src/contract/bash.js +43 -11
- package/src/contract/read.js +19 -5
- package/src/fs/background.js +304 -0
- package/src/fs/commit.js +6 -0
- package/src/fs/file-io.js +17 -2
- package/src/fs/process-tree.js +92 -0
- package/src/fs/read-window.js +18 -1
- package/src/fs/vfs.js +17 -4
- package/src/fs/workspace.js +51 -46
- package/src/output/bottleneck.js +14 -1
- package/src/runtime/guest-api.js +36 -20
- package/src/runtime/reference.js +11 -14
- package/src/shared/syntax-context.js +28 -7
|
@@ -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 = () =>
|
|
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
|
|
309
|
-
|
|
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,
|
package/src/bridge/invoke.js
CHANGED
|
@@ -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)
|
|
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" };
|
package/src/bridge/trace.js
CHANGED
|
@@ -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 };
|
package/src/context/evidence.js
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/context/fuzzy.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
78
|
-
*
|
|
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
|
|
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
|
|
90
|
-
|
|
91
|
-
if (firstAt < 0) firstAt = hi;
|
|
90
|
+
if (hi < 0) return { failAt: ni };
|
|
92
91
|
hi++;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
|
|
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 =
|
|
100
|
-
start = hayCmp.lastIndexOf(
|
|
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(
|
|
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(
|
|
126
|
-
for (let i = 0; i
|
|
127
|
-
const m = visit(
|
|
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
|
-
|
|
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 = (
|
|
142
|
-
const key =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
219
|
+
const needleLower = needle.toLowerCase();
|
|
220
|
+
const hayLower = hay.toLowerCase();
|
|
165
221
|
|
|
166
|
-
return
|
|
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(
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
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,
|
|
195
|
-
const
|
|
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,
|
|
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,
|
|
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 =
|
|
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,
|
|
317
|
+
function filenameBonus(base, rel, filenameStart, first, needleLower) {
|
|
245
318
|
if (first.start < filenameStart) return 0;
|
|
246
319
|
|
|
247
|
-
return rel.slice(filenameStart).toLowerCase() ===
|
|
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. */
|
package/src/context/query.js
CHANGED
|
@@ -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 =>
|
|
31
|
-
wantsType: words.some(word =>
|
|
32
|
-
wantsDoc: words.some(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
|
-
|
|
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 =>
|
|
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 (
|
|
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;
|