pi-supernova 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +24 -0
- package/src/context/repo-index.js +62 -0
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/context/evidence.js
CHANGED
|
@@ -33,9 +33,12 @@ const EVIDENCE_DEFAULTS = {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
const IDENT = /[A-Za-z_$][\w$]*/g;
|
|
36
|
+
|
|
36
37
|
// Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
|
|
37
38
|
const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
|
|
39
|
+
|
|
38
40
|
const HUB_FRACTION = 0.25;
|
|
41
|
+
|
|
39
42
|
const HUB_MIN = 8;
|
|
40
43
|
|
|
41
44
|
export { stem } from "./snap.js";
|
|
@@ -52,6 +55,7 @@ export function profileQuery(query) {
|
|
|
52
55
|
const subjects = words.filter((w) => /[a-z][A-Z]|_/.test(w));
|
|
53
56
|
const usage = words.some((w) => RELATION_WORDS.has(w.toLowerCase()));
|
|
54
57
|
const relational = usage || subjects.length > 0;
|
|
58
|
+
|
|
55
59
|
return {
|
|
56
60
|
subjects: [...new Set(subjects)],
|
|
57
61
|
keywords: tokens,
|
|
@@ -68,9 +72,11 @@ function spansOf(entry, filePath, maxSpanLines) {
|
|
|
68
72
|
const lines = WorkspaceIndex.linesOf(entry);
|
|
69
73
|
const base = { path: filePath, entry, lower: lines.lower, lines };
|
|
70
74
|
const declared = WorkspaceIndex.spansOf(entry);
|
|
75
|
+
|
|
71
76
|
if (declared.length === 0) {
|
|
72
77
|
return [{ ...base, id: filePath + ":1", start: 1, end: Math.min(lines.raw.length, maxSpanLines), name: path.basename(filePath), kind: "file", sourceEnd: lines.raw.length }];
|
|
73
78
|
}
|
|
79
|
+
|
|
74
80
|
return declared.map((s, i) => ({ ...base, ...s, sourceEnd: s.end, id: filePath + ":" + s.start, end: Math.min(s.end, s.start + maxSpanLines - 1), index: i }));
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -84,10 +90,12 @@ function buildGraph(spans) {
|
|
|
84
90
|
const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
|
|
85
91
|
const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
|
|
86
92
|
const entitySpans = new Map(); // entity → Set(span.id)
|
|
93
|
+
|
|
87
94
|
for (const span of spans) {
|
|
88
95
|
const counts = new Map();
|
|
89
96
|
let total = 0;
|
|
90
97
|
const { idents } = span.lines;
|
|
98
|
+
|
|
91
99
|
for (let li = span.start - 1; li < span.end; li++) {
|
|
92
100
|
for (const word of idents[li]) {
|
|
93
101
|
if (!entityNames.has(word)) continue;
|
|
@@ -95,17 +103,27 @@ function buildGraph(spans) {
|
|
|
95
103
|
total += 1;
|
|
96
104
|
}
|
|
97
105
|
}
|
|
106
|
+
|
|
98
107
|
const weights = new Map();
|
|
108
|
+
|
|
99
109
|
for (const [e, c] of counts) {
|
|
100
110
|
weights.set(e, c / total); // eq.4
|
|
111
|
+
|
|
101
112
|
if (!entitySpans.has(e)) entitySpans.set(e, new Set());
|
|
102
113
|
entitySpans.get(e).add(span.id);
|
|
103
114
|
}
|
|
115
|
+
|
|
104
116
|
spanEntities.set(span.id, weights);
|
|
105
117
|
}
|
|
118
|
+
|
|
106
119
|
// Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
|
|
107
120
|
const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
|
|
108
|
-
const hubs = new Set(
|
|
121
|
+
const hubs = new Set();
|
|
122
|
+
|
|
123
|
+
for (const [entity, ids] of entitySpans) {
|
|
124
|
+
if (ids.size > hubLimit) hubs.add(entity);
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
return { entityNames, spanEntities, entitySpans, hubs, byId: new Map(spans.map((s) => [s.id, s])) };
|
|
110
128
|
}
|
|
111
129
|
|
|
@@ -114,33 +132,43 @@ function buildGraph(spans) {
|
|
|
114
132
|
function lexicalSim(a, b) {
|
|
115
133
|
const ta = new Set(splitIdentifier(a));
|
|
116
134
|
const tb = new Set(splitIdentifier(b));
|
|
135
|
+
|
|
117
136
|
if (ta.size === 0 || tb.size === 0) return 0;
|
|
118
137
|
let inter = 0;
|
|
138
|
+
|
|
119
139
|
for (const t of ta) if (tb.has(t)) inter++;
|
|
140
|
+
|
|
120
141
|
return inter / (ta.size + tb.size - inter);
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
function activateEntities(profile, graph) {
|
|
124
145
|
const eta = new Map();
|
|
125
146
|
const anchors = profile.subjects.length ? profile.subjects : profile.keywords;
|
|
147
|
+
|
|
126
148
|
for (const anchor of anchors) {
|
|
127
149
|
let best = null;
|
|
128
150
|
let bestSim = 0;
|
|
151
|
+
|
|
129
152
|
for (const e of graph.entityNames) {
|
|
130
153
|
const sim = e.toLowerCase() === anchor.toLowerCase() ? 1 : lexicalSim(e, anchor);
|
|
154
|
+
|
|
131
155
|
if (sim > bestSim) {
|
|
132
156
|
bestSim = sim;
|
|
133
157
|
best = e;
|
|
134
158
|
}
|
|
135
159
|
}
|
|
160
|
+
|
|
136
161
|
if (best && bestSim >= 0.5) eta.set(best, Math.max(eta.get(best) || 0, bestSim)); // eq.8
|
|
137
162
|
}
|
|
163
|
+
|
|
138
164
|
return eta;
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
function querySim(profile, lowerLine) {
|
|
142
168
|
let hits = 0;
|
|
169
|
+
|
|
143
170
|
for (const t of profile.stems) if (lowerLine.includes(t)) hits++;
|
|
171
|
+
|
|
144
172
|
return profile.stems.length ? hits / profile.stems.length : 0;
|
|
145
173
|
}
|
|
146
174
|
|
|
@@ -155,10 +183,12 @@ function activateCooccurring(e, line, weight, spanId, graph, eta1) {
|
|
|
155
183
|
|
|
156
184
|
function propagateFrom(e, act, graph, profile, eta1) {
|
|
157
185
|
const eLower = e.toLowerCase();
|
|
186
|
+
|
|
158
187
|
for (const spanId of graph.entitySpans.get(e) || []) {
|
|
159
188
|
for (const line of spanLines(graph.byId.get(spanId))) {
|
|
160
189
|
if (!line.includes(eLower)) continue;
|
|
161
190
|
const sim = querySim(profile, line);
|
|
191
|
+
|
|
162
192
|
if (sim > 0) activateCooccurring(e, line, act * sim, spanId, graph, eta1);
|
|
163
193
|
}
|
|
164
194
|
}
|
|
@@ -166,7 +196,9 @@ function propagateFrom(e, act, graph, profile, eta1) {
|
|
|
166
196
|
|
|
167
197
|
function propagate(eta0, spans, graph, profile) {
|
|
168
198
|
const eta1 = new Map(eta0);
|
|
199
|
+
|
|
169
200
|
for (const [e, act] of eta0) propagateFrom(e, act, graph, profile, eta1);
|
|
201
|
+
|
|
170
202
|
return eta1;
|
|
171
203
|
}
|
|
172
204
|
|
|
@@ -176,13 +208,17 @@ function resetDistribution(spans, graph, eta, prior) {
|
|
|
176
208
|
const n = spans.length;
|
|
177
209
|
const reset = new Float64Array(n);
|
|
178
210
|
let sum = 0;
|
|
211
|
+
|
|
179
212
|
for (let i = 0; i < n; i++) {
|
|
180
213
|
let r = prior[i];
|
|
214
|
+
|
|
181
215
|
for (const [e, w] of graph.spanEntities.get(spans[i].id)) if (!graph.hubs.has(e)) r += (eta.get(e) || 0) * w;
|
|
182
216
|
reset[i] = r;
|
|
183
217
|
sum += r;
|
|
184
218
|
}
|
|
219
|
+
|
|
185
220
|
if (sum > 0) for (let i = 0; i < n; i++) reset[i] /= sum;
|
|
221
|
+
|
|
186
222
|
return { reset, sum };
|
|
187
223
|
}
|
|
188
224
|
|
|
@@ -192,60 +228,83 @@ function resetDistribution(spans, graph, eta, prior) {
|
|
|
192
228
|
*/
|
|
193
229
|
function transitionStructure(spans, graph) {
|
|
194
230
|
const n = spans.length;
|
|
195
|
-
const entities = [
|
|
231
|
+
const entities = [];
|
|
232
|
+
|
|
233
|
+
for (const [entity, ids] of graph.entitySpans) {
|
|
234
|
+
if (ids.size >= 2 && !graph.hubs.has(entity)) entities.push(entity);
|
|
235
|
+
}
|
|
236
|
+
|
|
196
237
|
const eIndex = new Map(entities.map((e, i) => [e, i]));
|
|
238
|
+
|
|
197
239
|
const spanTerms = spans.map((s) => {
|
|
198
240
|
const terms = [];
|
|
241
|
+
|
|
199
242
|
for (const [e, w] of graph.spanEntities.get(s.id)) if (eIndex.has(e)) terms.push([eIndex.get(e), w]);
|
|
243
|
+
|
|
200
244
|
return terms;
|
|
201
245
|
});
|
|
246
|
+
|
|
202
247
|
const entityMass = new Float64Array(entities.length);
|
|
248
|
+
|
|
203
249
|
for (let i = 0; i < n; i++) for (const [ei, w] of spanTerms[i]) entityMass[ei] += w;
|
|
204
250
|
const neighbours = spans.map((s, i) => [i - 1, i + 1].filter((j) => j >= 0 && j < n && spans[j].path === s.path));
|
|
205
251
|
const outWeight = new Float64Array(n);
|
|
252
|
+
|
|
206
253
|
for (let i = 0; i < n; i++) {
|
|
207
254
|
let out = 0.5 * neighbours[i].length;
|
|
255
|
+
|
|
208
256
|
for (const [ei, w] of spanTerms[i]) out += w * (entityMass[ei] - w);
|
|
209
257
|
outWeight[i] = out;
|
|
210
258
|
}
|
|
259
|
+
|
|
211
260
|
return { spanTerms, entityCount: entities.length, neighbours, outWeight };
|
|
212
261
|
}
|
|
213
262
|
|
|
214
263
|
function pushStep(pi, next, acc, structure, gamma) {
|
|
215
264
|
const { spanTerms, neighbours, outWeight } = structure;
|
|
216
265
|
let dangling = 0;
|
|
266
|
+
|
|
217
267
|
for (let i = 0; i < pi.length; i++) {
|
|
218
268
|
if (outWeight[i] === 0) {
|
|
219
269
|
dangling += pi[i];
|
|
220
270
|
continue;
|
|
221
271
|
}
|
|
272
|
+
|
|
222
273
|
const flow = (gamma * pi[i]) / outWeight[i];
|
|
274
|
+
|
|
223
275
|
for (const [ei, w] of spanTerms[i]) {
|
|
224
276
|
acc[ei] += flow * w;
|
|
225
277
|
next[i] -= flow * w * w; // remove the d → d self term
|
|
226
278
|
}
|
|
279
|
+
|
|
227
280
|
for (const j of neighbours[i]) next[j] += flow * 0.5;
|
|
228
281
|
}
|
|
282
|
+
|
|
229
283
|
return dangling;
|
|
230
284
|
}
|
|
231
285
|
|
|
232
286
|
function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
|
|
233
287
|
const n = spans.length;
|
|
234
288
|
const { reset, sum } = resetDistribution(spans, graph, eta, prior);
|
|
289
|
+
|
|
235
290
|
if (sum === 0) return reset;
|
|
236
291
|
const structure = transitionStructure(spans, graph);
|
|
237
292
|
const acc = new Float64Array(structure.entityCount);
|
|
238
293
|
let pi = Float64Array.from(reset);
|
|
294
|
+
|
|
239
295
|
for (let iter = 0; iter < pprIterations; iter++) {
|
|
240
296
|
const next = new Float64Array(n);
|
|
241
297
|
acc.fill(0);
|
|
242
298
|
const dangling = pushStep(pi, next, acc, structure, gamma);
|
|
299
|
+
|
|
243
300
|
for (let i = 0; i < n; i++) {
|
|
244
301
|
for (const [ei, w] of structure.spanTerms[i]) next[i] += acc[ei] * w;
|
|
245
302
|
next[i] += (1 - gamma) * reset[i] + gamma * dangling * reset[i]; // dangling mass follows the reset
|
|
246
303
|
}
|
|
304
|
+
|
|
247
305
|
pi = next;
|
|
248
306
|
}
|
|
307
|
+
|
|
249
308
|
return pi;
|
|
250
309
|
}
|
|
251
310
|
|
|
@@ -255,7 +314,9 @@ function nameDefinitionScore(span, profile, usage) {
|
|
|
255
314
|
if (usage) return 0; // eq.15 Rank_ϕ: a usage question is answered by callers, not the definer
|
|
256
315
|
const nameTokens = splitIdentifier(span.name || "");
|
|
257
316
|
let def = 0;
|
|
317
|
+
|
|
258
318
|
for (const t of profile.stems) if (nameTokens.some((n) => n.startsWith(t))) def += 40;
|
|
319
|
+
|
|
259
320
|
return def;
|
|
260
321
|
}
|
|
261
322
|
|
|
@@ -265,20 +326,26 @@ function mentionScore(span, profile, usage, skipDeclaration) {
|
|
|
265
326
|
let bestLine = span.start;
|
|
266
327
|
let bestHits = 0;
|
|
267
328
|
const lines = spanLines(span);
|
|
329
|
+
|
|
268
330
|
for (let i = skipDeclaration ? 1 : 0; i < lines.length; i++) {
|
|
269
331
|
let hits = 0;
|
|
332
|
+
|
|
270
333
|
for (const t of profile.stems) if (lines[i].includes(t)) hits++;
|
|
334
|
+
|
|
271
335
|
if (hits > bestHits) {
|
|
272
336
|
bestHits = hits;
|
|
273
337
|
bestLine = span.start + i;
|
|
274
338
|
}
|
|
339
|
+
|
|
275
340
|
mentions += hits * hits * perHit; // several query stems on one line is strong evidence
|
|
276
341
|
}
|
|
342
|
+
|
|
277
343
|
return { mentions, bestLine };
|
|
278
344
|
}
|
|
279
345
|
|
|
280
346
|
function hierarchicalScores(spans, fileScores, profile) {
|
|
281
347
|
const usage = profile.answerType === "usage";
|
|
348
|
+
|
|
282
349
|
return spans.map((span) => {
|
|
283
350
|
const definesSubject = profile.subjects.includes(span.name);
|
|
284
351
|
const def = nameDefinitionScore(span, profile, usage);
|
|
@@ -286,6 +353,7 @@ function hierarchicalScores(spans, fileScores, profile) {
|
|
|
286
353
|
span.bestLine = bestLine;
|
|
287
354
|
span.support = def + mentions; // span-level lexical evidence; file-level bonuses do not count
|
|
288
355
|
const fileScore = Math.max(0, fileScores.get(span.path) || 0);
|
|
356
|
+
|
|
289
357
|
return fileScore / 2 + def + Math.min(mentions, usage ? 200 : 120) + (span.isExport ? 10 : 0);
|
|
290
358
|
});
|
|
291
359
|
}
|
|
@@ -295,11 +363,15 @@ function hierarchicalScores(spans, fileScores, profile) {
|
|
|
295
363
|
function normalize(scores) {
|
|
296
364
|
let min = Infinity;
|
|
297
365
|
let max = -Infinity;
|
|
366
|
+
|
|
298
367
|
for (const s of scores) {
|
|
299
368
|
if (s < min) min = s;
|
|
369
|
+
|
|
300
370
|
if (s > max) max = s;
|
|
301
371
|
}
|
|
372
|
+
|
|
302
373
|
if (!(max > min)) return scores.map(() => 1);
|
|
374
|
+
|
|
303
375
|
return scores.map((s) => (s - min) / (max - min));
|
|
304
376
|
}
|
|
305
377
|
|
|
@@ -307,26 +379,36 @@ function normalize(scores) {
|
|
|
307
379
|
|
|
308
380
|
function candidateFiles(files, profile, index, limit, overlayText) {
|
|
309
381
|
const scored = [];
|
|
382
|
+
|
|
310
383
|
for (const f of files) {
|
|
311
384
|
const s = scorePathTopology(f, profile.keywords, profile.flags);
|
|
385
|
+
|
|
312
386
|
if (s > 0) scored.push({ f, s });
|
|
313
387
|
}
|
|
388
|
+
|
|
314
389
|
scored.sort((a, b) => b.s - a.s);
|
|
315
390
|
const chosen = new Set();
|
|
316
391
|
const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
392
|
+
|
|
317
393
|
const pendingHits = files.filter(file => {
|
|
318
394
|
const pending = overlayText(file);
|
|
395
|
+
|
|
319
396
|
return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
|
|
320
397
|
});
|
|
398
|
+
|
|
321
399
|
const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
|
|
400
|
+
|
|
322
401
|
for (const f of hits) {
|
|
323
402
|
if (chosen.size >= limit) break;
|
|
403
|
+
|
|
324
404
|
if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
|
|
325
405
|
}
|
|
406
|
+
|
|
326
407
|
for (const { f } of scored) {
|
|
327
408
|
if (chosen.size >= limit) break;
|
|
328
409
|
chosen.add(f);
|
|
329
410
|
}
|
|
411
|
+
|
|
330
412
|
return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
|
|
331
413
|
}
|
|
332
414
|
|
|
@@ -335,13 +417,17 @@ function candidateFiles(files, profile, index, limit, overlayText) {
|
|
|
335
417
|
function bridgesFor(i, spans, graph, fused, definers, chosen) {
|
|
336
418
|
const byId = graph.byId;
|
|
337
419
|
const bridges = [];
|
|
420
|
+
|
|
338
421
|
for (const [e, w] of graph.spanEntities.get(spans[i].id)) {
|
|
339
422
|
const defId = definers.get(e);
|
|
423
|
+
|
|
340
424
|
if (!defId || graph.hubs.has(e) || chosen.has(defId) || defId === spans[i].id || w < 0.15) continue;
|
|
341
425
|
const definer = byId.get(defId);
|
|
426
|
+
|
|
342
427
|
if (definer.end - definer.start < 2) continue; // one-line helpers add no understanding
|
|
343
428
|
bridges.push({ id: defId, w: w * fused[definer.index0] });
|
|
344
429
|
}
|
|
430
|
+
|
|
345
431
|
return bridges.sort((a, b) => b.w - a.w).slice(0, 2);
|
|
346
432
|
}
|
|
347
433
|
|
|
@@ -349,35 +435,45 @@ function closure(main, spans, graph, fused, k) {
|
|
|
349
435
|
spans.forEach((s, i) => (s.index0 = i));
|
|
350
436
|
const chosen = new Map(main.map((i) => [spans[i].id, { i, why: "main" }]));
|
|
351
437
|
const definers = new Map();
|
|
438
|
+
|
|
352
439
|
for (const s of spans) if (s.name) definers.set(s.name, s.id);
|
|
353
440
|
const neighboursOf = (i) => [i - 1, i + 1].filter((j) => j >= 0 && j < spans.length && spans[j].path === spans[i].path);
|
|
441
|
+
|
|
354
442
|
for (const i of main) {
|
|
355
443
|
// Ng: spans that define identifiers this span uses (relational bridges).
|
|
356
444
|
for (const b of bridgesFor(i, spans, graph, fused, definers, chosen)) chosen.set(b.id, { i: graph.byId.get(b.id).index0, why: "bridge" });
|
|
445
|
+
|
|
357
446
|
// Nh: in-file neighbours that still carry query signal.
|
|
358
447
|
for (const j of neighboursOf(i)) {
|
|
359
448
|
if (fused[j] > 0.2 && !chosen.has(spans[j].id)) chosen.set(spans[j].id, { i: j, why: "neighbor" });
|
|
360
449
|
}
|
|
361
450
|
}
|
|
451
|
+
|
|
362
452
|
const supports = [...chosen.values()].filter((c) => c.why !== "main").sort((a, b) => fused[b.i] - fused[a.i]).slice(0, k);
|
|
453
|
+
|
|
363
454
|
return [...main.map((i) => ({ i, why: "main" })), ...supports];
|
|
364
455
|
}
|
|
365
456
|
|
|
366
457
|
function render(spans, picks, fused, opts, root) {
|
|
367
458
|
const out = [];
|
|
368
459
|
let budget = opts.maxChars;
|
|
460
|
+
|
|
369
461
|
for (const { i, why } of picks) {
|
|
370
462
|
const span = spans[i];
|
|
371
463
|
const lines = span.lines.raw.slice(span.start - 1, span.end);
|
|
372
464
|
let text = lines.join("\n");
|
|
465
|
+
|
|
373
466
|
if (text.length > budget) {
|
|
374
467
|
const end = text.lastIndexOf("\n", budget);
|
|
468
|
+
|
|
375
469
|
if (end < 0) {
|
|
376
470
|
if (out.length) break;
|
|
377
471
|
throw new Error("evidence source line exceeds maxChars; increase the budget or read the file directly");
|
|
378
472
|
}
|
|
473
|
+
|
|
379
474
|
text = text.slice(0, end);
|
|
380
475
|
}
|
|
476
|
+
|
|
381
477
|
const lastLine = span.start + text.split("\n").length - 1;
|
|
382
478
|
const truncated = lastLine < span.sourceEnd;
|
|
383
479
|
budget -= text.length;
|
|
@@ -391,8 +487,10 @@ function render(spans, picks, fused, opts, root) {
|
|
|
391
487
|
why,
|
|
392
488
|
text,
|
|
393
489
|
});
|
|
490
|
+
|
|
394
491
|
if (budget <= 0) break;
|
|
395
492
|
}
|
|
493
|
+
|
|
396
494
|
return out;
|
|
397
495
|
}
|
|
398
496
|
|
|
@@ -403,23 +501,31 @@ function render(spans, picks, fused, opts, root) {
|
|
|
403
501
|
export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
|
|
404
502
|
const opts = { ...EVIDENCE_DEFAULTS, ...options };
|
|
405
503
|
const profile = profileQuery(query);
|
|
504
|
+
|
|
406
505
|
if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
|
|
407
506
|
const searchRoot = path.resolve(searchDir || root);
|
|
507
|
+
|
|
408
508
|
const staged = pendingPaths.filter(file => {
|
|
409
509
|
const relative = path.relative(searchRoot, file);
|
|
510
|
+
|
|
410
511
|
return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
|
|
411
512
|
});
|
|
513
|
+
|
|
412
514
|
const files = [...new Set([...await index.files(searchRoot), ...staged])];
|
|
515
|
+
|
|
413
516
|
if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
|
|
414
517
|
|
|
415
518
|
const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
|
|
416
519
|
const spans = [];
|
|
520
|
+
|
|
417
521
|
for (const f of chosenFiles) {
|
|
418
522
|
const pending = overlayText(f);
|
|
419
523
|
const entry = pending === undefined ? index.entry(f) : WorkspaceIndex.fromText(f, pending);
|
|
524
|
+
|
|
420
525
|
if (!entry) continue;
|
|
421
526
|
spans.push(...spansOf(entry, f, opts.maxSpanLines));
|
|
422
527
|
}
|
|
528
|
+
|
|
423
529
|
if (spans.length === 0) return { route: profile.route, spans: [] };
|
|
424
530
|
|
|
425
531
|
const graph = buildGraph(spans);
|
|
@@ -434,16 +540,24 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
|
|
|
434
540
|
|
|
435
541
|
// eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
|
|
436
542
|
const usage = profile.answerType === "usage";
|
|
437
|
-
|
|
438
|
-
|
|
543
|
+
|
|
544
|
+
const admissible = spans.flatMap((span, i) => {
|
|
545
|
+
const p = span.path;
|
|
439
546
|
const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
|
|
440
|
-
|
|
547
|
+
|
|
548
|
+
return span.support > 0
|
|
549
|
+
&& (profile.flags.wantsTest || !isTestPath(p))
|
|
550
|
+
&& (profile.flags.wantsDoc || !isDoc)
|
|
551
|
+
? [i] : [];
|
|
441
552
|
});
|
|
553
|
+
|
|
442
554
|
for (const i of admissible) {
|
|
443
555
|
if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
|
|
444
556
|
}
|
|
557
|
+
|
|
445
558
|
const ranked = admissible.sort((a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start);
|
|
446
559
|
const main = ranked.slice(0, opts.k);
|
|
447
560
|
const picks = closure(main, spans, graph, fused, opts.k);
|
|
561
|
+
|
|
448
562
|
return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
|
|
449
563
|
}
|
package/src/context/fuzzy.js
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
// - distance penalty from the current (last touched) file: −1 per directory hop, floor −20
|
|
12
12
|
|
|
13
13
|
const AI_DECAY = Math.LN2 / 3; // per day
|
|
14
|
+
|
|
14
15
|
const AI_MAX_HISTORY_DAYS = 7;
|
|
16
|
+
|
|
15
17
|
const MAX_TIMESTAMPS_PER_FILE = 128;
|
|
18
|
+
|
|
16
19
|
const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
|
|
17
20
|
|
|
18
21
|
export class Frecency {
|
|
@@ -22,8 +25,10 @@ export class Frecency {
|
|
|
22
25
|
|
|
23
26
|
record(filePath, at = Date.now() / 1000) {
|
|
24
27
|
let list = this.access.get(filePath);
|
|
28
|
+
|
|
25
29
|
if (!list) this.access.set(filePath, (list = []));
|
|
26
30
|
list.push(at);
|
|
31
|
+
|
|
27
32
|
if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -31,12 +36,15 @@ export class Frecency {
|
|
|
31
36
|
score(filePath, mtimeSec, now = Date.now() / 1000) {
|
|
32
37
|
let total = 0;
|
|
33
38
|
const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
|
|
39
|
+
|
|
34
40
|
for (const t of this.access.get(filePath) || []) {
|
|
35
41
|
if (t < cutoff) continue;
|
|
36
42
|
total += Math.exp(-AI_DECAY * ((now - t) / 86400));
|
|
37
43
|
}
|
|
44
|
+
|
|
38
45
|
if (mtimeSec) {
|
|
39
46
|
const age = now - mtimeSec;
|
|
47
|
+
|
|
40
48
|
for (const [boost, seconds] of AI_MODIFICATION_THRESHOLDS) {
|
|
41
49
|
if (age <= seconds) {
|
|
42
50
|
total += boost;
|
|
@@ -44,6 +52,7 @@ export class Frecency {
|
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
}
|
|
55
|
+
|
|
47
56
|
return total;
|
|
48
57
|
}
|
|
49
58
|
}
|
|
@@ -53,8 +62,10 @@ const SEPARATORS = new Set(["/", "\\", "_", "-", ".", " "]);
|
|
|
53
62
|
function isBoundary(hay, i) {
|
|
54
63
|
if (i === 0) return true;
|
|
55
64
|
const prev = hay[i - 1];
|
|
65
|
+
|
|
56
66
|
if (SEPARATORS.has(prev)) return true;
|
|
57
67
|
const c = hay[i];
|
|
68
|
+
|
|
58
69
|
return c >= "A" && c <= "Z" && !(prev >= "A" && prev <= "Z");
|
|
59
70
|
}
|
|
60
71
|
|
|
@@ -67,18 +78,24 @@ function matchOnce(needle, hay, caseSensitive) {
|
|
|
67
78
|
const nCmp = caseSensitive ? needle : needle.toLowerCase();
|
|
68
79
|
let hi = 0;
|
|
69
80
|
let firstAt = -1;
|
|
81
|
+
|
|
70
82
|
for (let ni = 0; ni < nCmp.length; ni++) {
|
|
71
83
|
hi = hayCmp.indexOf(nCmp[ni], hi);
|
|
84
|
+
|
|
72
85
|
if (hi < 0) return null;
|
|
86
|
+
|
|
73
87
|
if (firstAt < 0) firstAt = hi;
|
|
74
88
|
hi++;
|
|
75
89
|
}
|
|
90
|
+
|
|
76
91
|
const end = hi;
|
|
77
92
|
// Tighten: walk backwards from end to find the latest possible start.
|
|
78
93
|
let start = end;
|
|
94
|
+
|
|
79
95
|
for (let ni = nCmp.length - 1; ni >= 0; ni--) {
|
|
80
96
|
start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
|
|
81
97
|
}
|
|
98
|
+
|
|
82
99
|
return { score: scoreAlignment(needle, nCmp, hay, hayCmp, start), start, end };
|
|
83
100
|
}
|
|
84
101
|
|
|
@@ -87,6 +104,7 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
|
|
|
87
104
|
let score = 0;
|
|
88
105
|
let prev = -2;
|
|
89
106
|
let cursor = start;
|
|
107
|
+
|
|
90
108
|
for (let ni = 0; ni < nCmp.length; ni++) {
|
|
91
109
|
const at = hayCmp.indexOf(nCmp[ni], cursor);
|
|
92
110
|
score += isBoundary(hay, at) ? 16 : 0;
|
|
@@ -96,22 +114,29 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
|
|
|
96
114
|
prev = at;
|
|
97
115
|
cursor = at + 1;
|
|
98
116
|
}
|
|
117
|
+
|
|
99
118
|
return score;
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
/** Best match allowing up to maxTypos skipped needle characters. */
|
|
103
122
|
export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
|
|
104
123
|
const direct = matchOnce(needle, hay, caseSensitive);
|
|
124
|
+
|
|
105
125
|
if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
|
|
126
|
+
|
|
106
127
|
if (maxTypos <= 0 || needle.length < 3) return null;
|
|
107
128
|
let best = null;
|
|
129
|
+
|
|
108
130
|
for (let i = 0; i < needle.length; i++) {
|
|
109
131
|
const shorter = needle.slice(0, i) + needle.slice(i + 1);
|
|
110
132
|
const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
|
|
133
|
+
|
|
111
134
|
if (!m) continue;
|
|
112
135
|
const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
|
|
136
|
+
|
|
113
137
|
if (!best || scored.score > best.score) best = scored;
|
|
114
138
|
}
|
|
139
|
+
|
|
115
140
|
return best;
|
|
116
141
|
}
|
|
117
142
|
|
|
@@ -125,8 +150,10 @@ function distancePenalty(currentDir, candidateDir) {
|
|
|
125
150
|
const a = currentDir.split("/").filter(Boolean);
|
|
126
151
|
const b = candidateDir.split("/").filter(Boolean);
|
|
127
152
|
let common = 0;
|
|
153
|
+
|
|
128
154
|
while (common < a.length && common < b.length && a[common] === b[common]) common++;
|
|
129
155
|
const depth = a.length - common;
|
|
156
|
+
|
|
130
157
|
return Math.max(-20, -depth);
|
|
131
158
|
}
|
|
132
159
|
|
|
@@ -136,20 +163,25 @@ function distancePenalty(currentDir, candidateDir) {
|
|
|
136
163
|
*/
|
|
137
164
|
export function rankPaths(query, paths, ctx = {}) {
|
|
138
165
|
const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
|
|
166
|
+
|
|
139
167
|
if (parts.length === 0) return [];
|
|
140
168
|
const caseSensitive = smartCase(query);
|
|
141
169
|
const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
|
|
142
170
|
const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
|
|
143
171
|
const out = [];
|
|
172
|
+
|
|
144
173
|
for (const rel of paths) {
|
|
145
174
|
const matched = matchParts(parts, rel, maxTypos, caseSensitive);
|
|
175
|
+
|
|
146
176
|
if (!matched) continue;
|
|
147
177
|
const { base, first, exact } = matched;
|
|
148
178
|
const filenameStart = rel.lastIndexOf("/") + 1;
|
|
149
179
|
const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
|
|
150
180
|
out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
|
|
151
181
|
}
|
|
182
|
+
|
|
152
183
|
out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
|
|
184
|
+
|
|
153
185
|
return out;
|
|
154
186
|
}
|
|
155
187
|
|
|
@@ -158,19 +190,23 @@ function matchParts(parts, rel, maxTypos, caseSensitive) {
|
|
|
158
190
|
let sum = 0;
|
|
159
191
|
let first = null;
|
|
160
192
|
let exact = true;
|
|
193
|
+
|
|
161
194
|
for (let pi = 0; pi < parts.length; pi++) {
|
|
162
195
|
const m = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
|
|
196
|
+
|
|
163
197
|
if (!m) return null;
|
|
164
198
|
first ??= m;
|
|
165
199
|
sum += m.score;
|
|
166
200
|
exact = exact && m.exact;
|
|
167
201
|
}
|
|
202
|
+
|
|
168
203
|
return { base: Math.max(1, Math.round(sum / parts.length)), first, exact };
|
|
169
204
|
}
|
|
170
205
|
|
|
171
206
|
/** fff: exact filename +40% of base, any filename match +20%. */
|
|
172
207
|
function filenameBonus(base, rel, filenameStart, first, needle) {
|
|
173
208
|
if (first.start < filenameStart) return 0;
|
|
209
|
+
|
|
174
210
|
return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
|
|
175
211
|
}
|
|
176
212
|
|
|
@@ -178,5 +214,6 @@ function filenameBonus(base, rel, filenameStart, first, needle) {
|
|
|
178
214
|
function contextBoost(base, rel, ctx) {
|
|
179
215
|
const frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0;
|
|
180
216
|
const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
|
|
217
|
+
|
|
181
218
|
return Math.floor((base * frecency) / 100) + gitBoost;
|
|
182
219
|
}
|