pi-supernova 0.0.8 → 0.0.15

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/evidence.js ADDED
@@ -0,0 +1,429 @@
1
+ import * as path from "node:path";
2
+ import { WorkspaceIndex } from "./repo-index.js";
3
+ import { tokenizeQuery, scorePathTopology } from "./snap.js";
4
+ import { isTestPath } from "./workspace.js";
5
+
6
+ // Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
7
+ // The codebase is the interaction history H; declared spans are the context units;
8
+ // identifiers are the entities. Every step below is deterministic (no model call)
9
+ // and every returned unit carries provenance (path, lines) back to the raw source.
10
+ //
11
+ // eq.3 G = (Vd ∪ Ve, Ede ∪ Edd) span/identifier nodes, co-occurrence + adjacency edges
12
+ // eq.4 w(d,e) = c(e,d) / Σ_e' c(e',d) entity–span weight
13
+ // eq.5 T(H) = file ∪ span ∪ line ∪ local granularities
14
+ // eq.6 ϕ(q) = {subject, keywords, type, temporal, boundary}
15
+ // eq.7 Route(q) ∈ {relational, local} → primary view weight ρ
16
+ // eq.8 η0(e|q) = sim(e, ê) lexical alignment (no encoder)
17
+ // eq.9 η1(e') = Σ_e η0(e) Σ_{z ∈ Z(e)∩Z(e')} sim(q, z)
18
+ // eq.10 π = (1−γ) r + γ Pᵀ π personalized PageRank over spans
19
+ // eq.11 file → span → line coarse-to-fine hierarchical view
20
+ // eq.12 Ŝv(d) = (Sv(d) − min) / (max − min) per-view min-max normalisation
21
+ // eq.13 Sfuse = ρ Ŝprimary + (1−ρ) Ŝsecondary
22
+ // eq.14 C(q) = Dedup(M ∪ Ng(M) ∪ Nh(M)) closure: bridges + neighbours
23
+ // eq.15 R(q) = Rank_ϕ(Filter(C, ϕ)) deterministic calibration
24
+
25
+ const EVIDENCE_DEFAULTS = {
26
+ k: 5, // paper: Top-5 within 0.65 F1 of Top-10 at half the candidates
27
+ rho: 0.7, // primary-view weight
28
+ gamma: 0.85, // PPR damping
29
+ pprIterations: 20,
30
+ maxSpanLines: 60,
31
+ maxChars: 6000, // total text budget of R(q)
32
+ maxCandidateFiles: 24,
33
+ };
34
+
35
+ const IDENT = /[A-Za-z_$][\w$]*/g;
36
+ // Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
37
+ const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
38
+ const HUB_FRACTION = 0.25;
39
+ const HUB_MIN = 8;
40
+
41
+ /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
42
+ export function stem(token) {
43
+ if (token.length < 5) return token;
44
+ return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
45
+ }
46
+
47
+ function splitIdentifier(name) {
48
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
49
+ }
50
+
51
+ /** eq.6: query profile with subjects, keywords/stems, answer type, test/doc flags, route. */
52
+ export function profileQuery(query) {
53
+ const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
54
+ const words = query.match(IDENT) || [];
55
+ // Subjects are identifier-shaped words (camelCase / snake_case): they anchor the graph view.
56
+ const subjects = words.filter((w) => /[a-z][A-Z]|_/.test(w));
57
+ const usage = words.some((w) => RELATION_WORDS.has(w.toLowerCase()));
58
+ const relational = usage || subjects.length > 0;
59
+ return {
60
+ subjects: [...new Set(subjects)],
61
+ keywords: tokens,
62
+ stems: [...new Set(tokens.map(stem))],
63
+ answerType: usage ? "usage" : "definition",
64
+ flags: { wantsTest, wantsType, wantsDoc },
65
+ route: relational ? "relational" : "local", // eq.7
66
+ };
67
+ }
68
+
69
+ // ---- substrate: spans (context units) from the structural surface ----
70
+
71
+ function spansOf(entry, filePath, maxSpanLines) {
72
+ const lines = WorkspaceIndex.linesOf(entry);
73
+ const base = { path: filePath, entry, lower: lines.lower, lines };
74
+ const declared = WorkspaceIndex.spansOf(entry);
75
+ if (declared.length === 0) {
76
+ return [{ ...base, id: filePath + ":1", start: 1, end: Math.min(lines.raw.length, maxSpanLines), name: path.basename(filePath), kind: "file" }];
77
+ }
78
+ return declared.map((s, i) => ({ ...base, ...s, id: filePath + ":" + s.start, end: Math.min(s.end, s.start + maxSpanLines - 1), index: i }));
79
+ }
80
+
81
+ function spanLines(span) {
82
+ return span.lower.slice(span.start - 1, span.end);
83
+ }
84
+
85
+ // ---- eq.3 / eq.4: entity–context graph over candidate spans ----
86
+
87
+ function buildGraph(spans) {
88
+ const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
89
+ const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
90
+ const entitySpans = new Map(); // entity → Set(span.id)
91
+ for (const span of spans) {
92
+ const counts = new Map();
93
+ let total = 0;
94
+ const { idents } = span.lines;
95
+ for (let li = span.start - 1; li < span.end; li++) {
96
+ for (const word of idents[li]) {
97
+ if (!entityNames.has(word)) continue;
98
+ counts.set(word, (counts.get(word) || 0) + 1);
99
+ total += 1;
100
+ }
101
+ }
102
+ const weights = new Map();
103
+ for (const [e, c] of counts) {
104
+ weights.set(e, c / total); // eq.4
105
+ if (!entitySpans.has(e)) entitySpans.set(e, new Set());
106
+ entitySpans.get(e).add(span.id);
107
+ }
108
+ spanEntities.set(span.id, weights);
109
+ }
110
+ // Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
111
+ const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
112
+ const hubs = new Set([...entitySpans].filter(([, ids]) => ids.size > hubLimit).map(([e]) => e));
113
+ return { entityNames, spanEntities, entitySpans, hubs, byId: new Map(spans.map((s) => [s.id, s])) };
114
+ }
115
+
116
+ // ---- eq.8 / eq.9: entity activation and one propagation step ----
117
+
118
+ function lexicalSim(a, b) {
119
+ const ta = new Set(splitIdentifier(a));
120
+ const tb = new Set(splitIdentifier(b));
121
+ if (ta.size === 0 || tb.size === 0) return 0;
122
+ let inter = 0;
123
+ for (const t of ta) if (tb.has(t)) inter++;
124
+ return inter / (ta.size + tb.size - inter);
125
+ }
126
+
127
+ function activateEntities(profile, graph) {
128
+ const eta = new Map();
129
+ const anchors = profile.subjects.length ? profile.subjects : profile.keywords;
130
+ for (const anchor of anchors) {
131
+ let best = null;
132
+ let bestSim = 0;
133
+ for (const e of graph.entityNames) {
134
+ const sim = e.toLowerCase() === anchor.toLowerCase() ? 1 : lexicalSim(e, anchor);
135
+ if (sim > bestSim) {
136
+ bestSim = sim;
137
+ best = e;
138
+ }
139
+ }
140
+ if (best && bestSim >= 0.5) eta.set(best, Math.max(eta.get(best) || 0, bestSim)); // eq.8
141
+ }
142
+ return eta;
143
+ }
144
+
145
+ function querySim(profile, lowerLine) {
146
+ let hits = 0;
147
+ for (const t of profile.stems) if (lowerLine.includes(t)) hits++;
148
+ return profile.stems.length ? hits / profile.stems.length : 0;
149
+ }
150
+
151
+ /** Co-occurring entities on one query-relevant line receive act·sim(q,z)·idf (eq.9, IDF-damped). */
152
+ function activateCooccurring(e, line, weight, spanId, graph, eta1) {
153
+ for (const [other] of graph.spanEntities.get(spanId)) {
154
+ if (other === e || graph.hubs.has(other) || !line.includes(other.toLowerCase())) continue;
155
+ const idf = 1 / Math.log2(1 + graph.entitySpans.get(other).size);
156
+ eta1.set(other, (eta1.get(other) || 0) + weight * idf);
157
+ }
158
+ }
159
+
160
+ function propagateFrom(e, act, graph, profile, eta1) {
161
+ const eLower = e.toLowerCase();
162
+ for (const spanId of graph.entitySpans.get(e) || []) {
163
+ for (const line of spanLines(graph.byId.get(spanId))) {
164
+ if (!line.includes(eLower)) continue;
165
+ const sim = querySim(profile, line);
166
+ if (sim > 0) activateCooccurring(e, line, act * sim, spanId, graph, eta1);
167
+ }
168
+ }
169
+ }
170
+
171
+ function propagate(eta0, spans, graph, profile) {
172
+ const eta1 = new Map(eta0);
173
+ for (const [e, act] of eta0) propagateFrom(e, act, graph, profile, eta1);
174
+ return eta1;
175
+ }
176
+
177
+ // ---- eq.10: personalized PageRank over spans ----
178
+
179
+ function resetDistribution(spans, graph, eta, prior) {
180
+ const n = spans.length;
181
+ const reset = new Float64Array(n);
182
+ let sum = 0;
183
+ for (let i = 0; i < n; i++) {
184
+ let r = prior[i];
185
+ for (const [e, w] of graph.spanEntities.get(spans[i].id)) if (!graph.hubs.has(e)) r += (eta.get(e) || 0) * w;
186
+ reset[i] = r;
187
+ sum += r;
188
+ }
189
+ if (sum > 0) for (let i = 0; i < n; i++) reset[i] /= sum;
190
+ return { reset, sum };
191
+ }
192
+
193
+ /**
194
+ * Transition structure d → d' = Σ_e w(d,e)·w(d',e) over shared non-hub entities plus 0.5 per in-file
195
+ * neighbour (Edd). Kept factored through the entity layer so an iteration costs O(nnz), never O(n²).
196
+ */
197
+ function transitionStructure(spans, graph) {
198
+ const n = spans.length;
199
+ const entities = [...graph.entitySpans].filter(([e, ids]) => ids.size >= 2 && !graph.hubs.has(e)).map(([e]) => e);
200
+ const eIndex = new Map(entities.map((e, i) => [e, i]));
201
+ const spanTerms = spans.map((s) => {
202
+ const terms = [];
203
+ for (const [e, w] of graph.spanEntities.get(s.id)) if (eIndex.has(e)) terms.push([eIndex.get(e), w]);
204
+ return terms;
205
+ });
206
+ const entityMass = new Float64Array(entities.length);
207
+ for (let i = 0; i < n; i++) for (const [ei, w] of spanTerms[i]) entityMass[ei] += w;
208
+ const neighbours = spans.map((s, i) => [i - 1, i + 1].filter((j) => j >= 0 && j < n && spans[j].path === s.path));
209
+ const outWeight = new Float64Array(n);
210
+ for (let i = 0; i < n; i++) {
211
+ let out = 0.5 * neighbours[i].length;
212
+ for (const [ei, w] of spanTerms[i]) out += w * (entityMass[ei] - w);
213
+ outWeight[i] = out;
214
+ }
215
+ return { spanTerms, entityCount: entities.length, neighbours, outWeight };
216
+ }
217
+
218
+ function pushStep(pi, next, acc, structure, gamma) {
219
+ const { spanTerms, neighbours, outWeight } = structure;
220
+ let dangling = 0;
221
+ for (let i = 0; i < pi.length; i++) {
222
+ if (outWeight[i] === 0) {
223
+ dangling += pi[i];
224
+ continue;
225
+ }
226
+ const flow = (gamma * pi[i]) / outWeight[i];
227
+ for (const [ei, w] of spanTerms[i]) {
228
+ acc[ei] += flow * w;
229
+ next[i] -= flow * w * w; // remove the d → d self term
230
+ }
231
+ for (const j of neighbours[i]) next[j] += flow * 0.5;
232
+ }
233
+ return dangling;
234
+ }
235
+
236
+ function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
237
+ const n = spans.length;
238
+ const { reset, sum } = resetDistribution(spans, graph, eta, prior);
239
+ if (sum === 0) return reset;
240
+ const structure = transitionStructure(spans, graph);
241
+ const acc = new Float64Array(structure.entityCount);
242
+ let pi = Float64Array.from(reset);
243
+ for (let iter = 0; iter < pprIterations; iter++) {
244
+ const next = new Float64Array(n);
245
+ acc.fill(0);
246
+ const dangling = pushStep(pi, next, acc, structure, gamma);
247
+ for (let i = 0; i < n; i++) {
248
+ for (const [ei, w] of structure.spanTerms[i]) next[i] += acc[ei] * w;
249
+ next[i] += (1 - gamma) * reset[i] + gamma * dangling * reset[i]; // dangling mass follows the reset
250
+ }
251
+ pi = next;
252
+ }
253
+ return pi;
254
+ }
255
+
256
+ // ---- eq.11: hierarchical view (file → span → line) ----
257
+
258
+ function nameDefinitionScore(span, profile, usage) {
259
+ if (usage) return 0; // eq.15 Rank_ϕ: a usage question is answered by callers, not the definer
260
+ const nameTokens = splitIdentifier(span.name || "");
261
+ let def = 0;
262
+ for (const t of profile.stems) if (nameTokens.some((n) => n.startsWith(t))) def += 40;
263
+ return def;
264
+ }
265
+
266
+ function mentionScore(span, profile, usage, skipDeclaration) {
267
+ const perHit = usage ? 15 : 5;
268
+ let mentions = 0;
269
+ let bestLine = span.start;
270
+ let bestHits = 0;
271
+ const lines = spanLines(span);
272
+ for (let i = skipDeclaration ? 1 : 0; i < lines.length; i++) {
273
+ let hits = 0;
274
+ for (const t of profile.stems) if (lines[i].includes(t)) hits++;
275
+ if (hits > bestHits) {
276
+ bestHits = hits;
277
+ bestLine = span.start + i;
278
+ }
279
+ mentions += hits * hits * perHit; // several query stems on one line is strong evidence
280
+ }
281
+ return { mentions, bestLine };
282
+ }
283
+
284
+ function hierarchicalScores(spans, fileScores, profile) {
285
+ const usage = profile.answerType === "usage";
286
+ return spans.map((span) => {
287
+ const definesSubject = profile.subjects.includes(span.name);
288
+ const def = nameDefinitionScore(span, profile, usage);
289
+ const { mentions, bestLine } = mentionScore(span, profile, usage, usage && definesSubject);
290
+ span.bestLine = bestLine;
291
+ span.support = def + mentions; // span-level lexical evidence; file-level bonuses do not count
292
+ const fileScore = Math.max(0, fileScores.get(span.path) || 0);
293
+ return fileScore / 2 + def + Math.min(mentions, usage ? 200 : 120) + (span.isExport ? 10 : 0);
294
+ });
295
+ }
296
+
297
+ // ---- eq.12 / eq.13 ----
298
+
299
+ function normalize(scores) {
300
+ let min = Infinity;
301
+ let max = -Infinity;
302
+ for (const s of scores) {
303
+ if (s < min) min = s;
304
+ if (s > max) max = s;
305
+ }
306
+ if (!(max > min)) return scores.map(() => 1);
307
+ return scores.map((s) => (s - min) / (max - min));
308
+ }
309
+
310
+ // ---- candidate files (boundary + topology + entity hits) ----
311
+
312
+ function candidateFiles(files, profile, index, limit) {
313
+ const scored = [];
314
+ for (const f of files) {
315
+ const s = scorePathTopology(f, profile.keywords, profile.flags);
316
+ if (s > 0) scored.push({ f, s });
317
+ }
318
+ scored.sort((a, b) => b.s - a.s);
319
+ const chosen = new Set(scored.slice(0, limit).map(({ f }) => f));
320
+ const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
321
+ const hits = anchors.length ? index.filesContaining(files, anchors, true) : [];
322
+ for (const f of hits) {
323
+ if (chosen.size >= limit) break;
324
+ if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
325
+ }
326
+ return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
327
+ }
328
+
329
+ // ---- eq.14 / eq.15 ----
330
+
331
+ function bridgesFor(i, spans, graph, fused, definers, chosen) {
332
+ const byId = graph.byId;
333
+ const bridges = [];
334
+ for (const [e, w] of graph.spanEntities.get(spans[i].id)) {
335
+ const defId = definers.get(e);
336
+ if (!defId || graph.hubs.has(e) || chosen.has(defId) || defId === spans[i].id || w < 0.15) continue;
337
+ const definer = byId.get(defId);
338
+ if (definer.end - definer.start < 2) continue; // one-line helpers add no understanding
339
+ bridges.push({ id: defId, w: w * fused[definer.index0] });
340
+ }
341
+ return bridges.sort((a, b) => b.w - a.w).slice(0, 2);
342
+ }
343
+
344
+ function closure(main, spans, graph, fused, k) {
345
+ spans.forEach((s, i) => (s.index0 = i));
346
+ const chosen = new Map(main.map((i) => [spans[i].id, { i, why: "main" }]));
347
+ const definers = new Map();
348
+ for (const s of spans) if (s.name) definers.set(s.name, s.id);
349
+ const neighboursOf = (i) => [i - 1, i + 1].filter((j) => j >= 0 && j < spans.length && spans[j].path === spans[i].path);
350
+ for (const i of main) {
351
+ // Ng: spans that define identifiers this span uses (relational bridges).
352
+ for (const b of bridgesFor(i, spans, graph, fused, definers, chosen)) chosen.set(b.id, { i: graph.byId.get(b.id).index0, why: "bridge" });
353
+ // Nh: in-file neighbours that still carry query signal.
354
+ for (const j of neighboursOf(i)) {
355
+ if (fused[j] > 0.2 && !chosen.has(spans[j].id)) chosen.set(spans[j].id, { i: j, why: "neighbor" });
356
+ }
357
+ }
358
+ const supports = [...chosen.values()].filter((c) => c.why !== "main").sort((a, b) => fused[b.i] - fused[a.i]).slice(0, k);
359
+ return [...main.map((i) => ({ i, why: "main" })), ...supports];
360
+ }
361
+
362
+ function render(spans, picks, fused, opts, root) {
363
+ const out = [];
364
+ let budget = opts.maxChars;
365
+ for (const { i, why } of picks) {
366
+ const span = spans[i];
367
+ const lines = span.lines.raw.slice(span.start - 1, span.end);
368
+ let text = lines.join("\n");
369
+ if (text.length > budget) text = text.slice(0, Math.max(0, budget - 1)) + "…";
370
+ budget -= text.length;
371
+ out.push({
372
+ path: path.relative(root, span.path) || span.path,
373
+ lines: [span.start, span.start + lines.length - 1],
374
+ name: span.name,
375
+ kind: span.kind,
376
+ why,
377
+ text,
378
+ });
379
+ if (budget <= 0) break;
380
+ }
381
+ return out;
382
+ }
383
+
384
+ /**
385
+ * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
386
+ * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
387
+ */
388
+ export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, options = {} }) {
389
+ const opts = { ...EVIDENCE_DEFAULTS, ...options };
390
+ const profile = profileQuery(query);
391
+ if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
392
+ const files = await index.files(searchDir || root);
393
+ if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
394
+
395
+ const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles);
396
+ const spans = [];
397
+ for (const f of chosenFiles) {
398
+ const pending = overlayText(f);
399
+ const entry = pending === undefined ? index.entry(f) : WorkspaceIndex.fromText(f, pending);
400
+ if (!entry) continue;
401
+ spans.push(...spansOf(entry, f, opts.maxSpanLines));
402
+ }
403
+ if (spans.length === 0) return { route: profile.route, spans: [] };
404
+
405
+ const graph = buildGraph(spans);
406
+ const hier = hierarchicalScores(spans, fileScores, profile);
407
+ const hierNorm = normalize(hier);
408
+ const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
409
+ const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
410
+ const graphNorm = normalize([...pi]);
411
+
412
+ const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
413
+ const fused = primary.map((p, i) => opts.rho * p + (1 - opts.rho) * secondary[i]); // eq.13
414
+
415
+ // eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
416
+ const usage = profile.answerType === "usage";
417
+ const admissible = spans.map((s, i) => i).filter((i) => {
418
+ const p = spans[i].path;
419
+ const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
420
+ return spans[i].support > 0 && (profile.flags.wantsTest || !isTestPath(p)) && (profile.flags.wantsDoc || !isDoc);
421
+ });
422
+ for (const i of admissible) {
423
+ if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
424
+ }
425
+ const ranked = admissible.sort((a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start);
426
+ const main = ranked.slice(0, opts.k);
427
+ const picks = closure(main, spans, graph, fused, opts.k);
428
+ return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
429
+ }
package/fuzzy.js ADDED
@@ -0,0 +1,182 @@
1
+ // Typo-resistant fuzzy path matching and frecency, ported from fff (dmtrKovalenko/fff)
2
+ // to plain JS so path search stays in-process: no binary, no spawn.
3
+ //
4
+ // fff pieces reproduced here:
5
+ // - frizbee-style fuzzy match with max_typos (skipped needle chars), boundary / consecutive /
6
+ // capitalization bonuses, smart-case (uppercase in query ⇒ case-sensitive)
7
+ // - filename bonus: exact filename +40% of base, filename match +20%
8
+ // - frecency boost: base × frecency / 100, AI-mode decay (3-day half-life, 7-day window)
9
+ // plus modification-recency boosts (30s/5m/15m/1h/4h thresholds)
10
+ // - git-modified boost: +15% of base
11
+ // - distance penalty from the current (last touched) file: −1 per directory hop, floor −20
12
+
13
+ const AI_DECAY = Math.LN2 / 3; // per day
14
+ const AI_MAX_HISTORY_DAYS = 7;
15
+ const MAX_TIMESTAMPS_PER_FILE = 128;
16
+ const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
17
+
18
+ export class Frecency {
19
+ constructor() {
20
+ this.access = new Map(); // path → number[] (epoch seconds, newest last)
21
+ }
22
+
23
+ record(filePath, at = Date.now() / 1000) {
24
+ let list = this.access.get(filePath);
25
+ if (!list) this.access.set(filePath, (list = []));
26
+ list.push(at);
27
+ if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
28
+ }
29
+
30
+ /** Σ exp(−λ·age) over accesses in the window, plus a step boost for a recently modified file. */
31
+ score(filePath, mtimeSec, now = Date.now() / 1000) {
32
+ let total = 0;
33
+ const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
34
+ for (const t of this.access.get(filePath) || []) {
35
+ if (t < cutoff) continue;
36
+ total += Math.exp(-AI_DECAY * ((now - t) / 86400));
37
+ }
38
+ if (mtimeSec) {
39
+ const age = now - mtimeSec;
40
+ for (const [boost, seconds] of AI_MODIFICATION_THRESHOLDS) {
41
+ if (age <= seconds) {
42
+ total += boost;
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ return total;
48
+ }
49
+ }
50
+
51
+ const SEPARATORS = new Set(["/", "\\", "_", "-", ".", " "]);
52
+
53
+ function isBoundary(hay, i) {
54
+ if (i === 0) return true;
55
+ const prev = hay[i - 1];
56
+ if (SEPARATORS.has(prev)) return true;
57
+ const c = hay[i];
58
+ return c >= "A" && c <= "Z" && !(prev >= "A" && prev <= "Z");
59
+ }
60
+
61
+ /**
62
+ * Greedy forward match with backward tightening (fzf v1). Returns null or
63
+ * { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
64
+ */
65
+ function matchOnce(needle, hay, caseSensitive) {
66
+ const hayCmp = caseSensitive ? hay : hay.toLowerCase();
67
+ const nCmp = caseSensitive ? needle : needle.toLowerCase();
68
+ let hi = 0;
69
+ let firstAt = -1;
70
+ for (let ni = 0; ni < nCmp.length; ni++) {
71
+ hi = hayCmp.indexOf(nCmp[ni], hi);
72
+ if (hi < 0) return null;
73
+ if (firstAt < 0) firstAt = hi;
74
+ hi++;
75
+ }
76
+ const end = hi;
77
+ // Tighten: walk backwards from end to find the latest possible start.
78
+ let start = end;
79
+ for (let ni = nCmp.length - 1; ni >= 0; ni--) {
80
+ start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
81
+ }
82
+ return { score: scoreAlignment(needle, nCmp, hay, hayCmp, start), start, end };
83
+ }
84
+
85
+ /** +16 boundary, +8 consecutive, +4 exact-case, −1 per skipped haystack char. */
86
+ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
87
+ let score = 0;
88
+ let prev = -2;
89
+ let cursor = start;
90
+ for (let ni = 0; ni < nCmp.length; ni++) {
91
+ const at = hayCmp.indexOf(nCmp[ni], cursor);
92
+ score += isBoundary(hay, at) ? 16 : 0;
93
+ score += at === prev + 1 ? 8 : 0;
94
+ score += hay[at] === needle[ni] ? 4 : 0;
95
+ score -= prev >= 0 ? at - prev - 1 : 0;
96
+ prev = at;
97
+ cursor = at + 1;
98
+ }
99
+ return score;
100
+ }
101
+
102
+ /** Best match allowing up to maxTypos skipped needle characters. */
103
+ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
104
+ const direct = matchOnce(needle, hay, caseSensitive);
105
+ if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
106
+ if (maxTypos <= 0 || needle.length < 3) return null;
107
+ let best = null;
108
+ for (let i = 0; i < needle.length; i++) {
109
+ const shorter = needle.slice(0, i) + needle.slice(i + 1);
110
+ const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
111
+ if (!m) continue;
112
+ const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
113
+ if (!best || scored.score > best.score) best = scored;
114
+ }
115
+ return best;
116
+ }
117
+
118
+ export function smartCase(query) {
119
+ return /[A-Z]/.test(query);
120
+ }
121
+
122
+ /** fff distance penalty: directory hops from the current file's directory, floor −20. */
123
+ function distancePenalty(currentDir, candidateDir) {
124
+ if (!currentDir) return 0;
125
+ const a = currentDir.split("/").filter(Boolean);
126
+ const b = candidateDir.split("/").filter(Boolean);
127
+ let common = 0;
128
+ while (common < a.length && common < b.length && a[common] === b[common]) common++;
129
+ const depth = a.length - common;
130
+ return Math.max(-20, -depth);
131
+ }
132
+
133
+ /**
134
+ * Rank file paths for a query the fff way. paths are workspace-relative "/"-joined.
135
+ * ctx: { frecency: Frecency, mtimeOf: (path) => sec, modified: Set(path), currentFile?: string, maxTypos }
136
+ */
137
+ export function rankPaths(query, paths, ctx = {}) {
138
+ const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
139
+ if (parts.length === 0) return [];
140
+ const caseSensitive = smartCase(query);
141
+ const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
142
+ const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
143
+ const out = [];
144
+ for (const rel of paths) {
145
+ const matched = matchParts(parts, rel, maxTypos, caseSensitive);
146
+ if (!matched) continue;
147
+ const { base, first, exact } = matched;
148
+ const filenameStart = rel.lastIndexOf("/") + 1;
149
+ const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
150
+ out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
151
+ }
152
+ out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
153
+ return out;
154
+ }
155
+
156
+ /** Every query part must match; later parts get at most one typo (fff narrows per part). Score is the average. */
157
+ function matchParts(parts, rel, maxTypos, caseSensitive) {
158
+ let sum = 0;
159
+ let first = null;
160
+ let exact = true;
161
+ for (let pi = 0; pi < parts.length; pi++) {
162
+ const m = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
163
+ if (!m) return null;
164
+ first ??= m;
165
+ sum += m.score;
166
+ exact = exact && m.exact;
167
+ }
168
+ return { base: Math.max(1, Math.round(sum / parts.length)), first, exact };
169
+ }
170
+
171
+ /** fff: exact filename +40% of base, any filename match +20%. */
172
+ function filenameBonus(base, rel, filenameStart, first, needle) {
173
+ if (first.start < filenameStart) return 0;
174
+ return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
175
+ }
176
+
177
+ /** fff: frecency boost base·f/100 and +15% for git-modified files. */
178
+ function contextBoost(base, rel, ctx) {
179
+ const frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0;
180
+ const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
181
+ return Math.floor((base * frecency) / 100) + gitBoost;
182
+ }
package/guest-worker.js CHANGED
@@ -10,8 +10,8 @@ const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
10
10
  const compiledCache = new Map();
11
11
  const COMPILED_CACHE_MAX = 256;
12
12
  const PARAMS = [
13
- "nova", "tools", "console", "parallel", "pipeline",
14
- "read", "write", "edit", "patch", "surface", "snap", "bash", "exec", "speculate",
13
+ "nova", "console", "parallel", "pipeline",
14
+ "read", "write", "edit", "patch", "surface", "snap", "evidence", "bash", "exec", "speculate",
15
15
  ];
16
16
  // V8 and JSC both place the body on the line after the synthesized header.
17
17
  const BODY_LINE_OFFSET = 2;
@@ -228,19 +228,22 @@ function buildGuestApi(available) {
228
228
  return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
229
229
  }
230
230
  },
231
- surface: async (filePath) => unwrapJsonValue(await rpc("surface", [filePath])),
232
- snap: async (query, targetPath) => unwrapJsonValue(await rpc("snap", [query, targetPath])),
231
+ surface: async (filePath) => unwrapJsonValue(await rpc("call", ["surface", { path: filePath }])),
232
+ evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
233
+ snap: async (query, targetPath) => unwrapJsonValue(await rpc("call", ["snap", { query, path: targetPath }])),
233
234
  has: (name) => availableSet.has(name),
234
235
  };
235
236
 
236
- const read = async (p, offset, limit) => {
237
+ // read(path, offset?, limit?) or read(path, { about, offset, limit, maxChars })
238
+ const readArgs = (p, a, b) => (isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
239
+ const read = async (p, a, b) => {
237
240
  if (Array.isArray(p)) {
238
- const res = await nova.call("read", { path: p, offset, limit });
241
+ const res = await nova.call("read", readArgs(p, a, b));
239
242
  if (Array.isArray(res?.items)) return res.items;
240
243
  // Captured host executor without batch support: fan out.
241
- return Promise.all(p.map((item) => read(item, offset, limit)));
244
+ return Promise.all(p.map((item) => read(item, a, b)));
242
245
  }
243
- return unwrapValue(await nova.call("read", { path: p, offset, limit }));
246
+ return unwrapValue(await nova.call("read", readArgs(p, a, b)));
244
247
  };
245
248
  const write = async (p, content) => unwrapValue(await nova.call("write", { path: p, content }));
246
249
  const edit = async (p, oldText, newText) => unwrapValue(await nova.call("edit", { path: p, oldText, newText }));
@@ -269,7 +272,7 @@ function buildGuestApi(available) {
269
272
  return bash([command, ...args].map(quoteShellArg).join(" "), opts);
270
273
  };
271
274
 
272
- return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, bash, exec, speculate: nova.speculate };
275
+ return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
273
276
  }
274
277
 
275
278
  function makeConsole(runId, limits) {
@@ -311,8 +314,8 @@ async function handleRun(msg) {
311
314
  const scopedConsole = makeConsole(runId, limits);
312
315
  try {
313
316
  const value = await compiled(
314
- api.nova, api.nova, scopedConsole, runParallel, runPipeline,
315
- api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.bash, api.exec, api.speculate,
317
+ api.nova, scopedConsole, runParallel, runPipeline,
318
+ api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
316
319
  );
317
320
  if (runId !== activeRunId) return;
318
321
  let plain;