pi-supernova 0.8.2 → 0.9.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.
Files changed (66) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/query.js +71 -0
  21. package/src/context/repo-index.js +8 -162
  22. package/src/context/search-files.js +19 -0
  23. package/src/context/search.js +2 -24
  24. package/src/context/snap-search.js +202 -0
  25. package/src/context/snap.js +5 -266
  26. package/src/context/source-entry.js +112 -0
  27. package/src/contract/bash.js +6 -1
  28. package/src/contract/program.js +36 -0
  29. package/src/contract/read.js +8 -53
  30. package/src/fs/check.js +1 -1
  31. package/src/fs/commit.js +161 -0
  32. package/src/fs/diff.js +11 -15
  33. package/src/fs/directory.js +79 -0
  34. package/src/fs/file-io.js +100 -0
  35. package/src/fs/glob.js +54 -0
  36. package/src/fs/json-size.js +54 -0
  37. package/src/fs/lines.js +117 -0
  38. package/src/fs/read-window.js +74 -0
  39. package/src/fs/session-resource.js +50 -0
  40. package/src/fs/text-ops.js +7 -227
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +2 -1
  43. package/src/output/bottleneck.js +13 -67
  44. package/src/output/final.js +114 -0
  45. package/src/output/format.js +94 -5
  46. package/src/output/outcome.js +91 -0
  47. package/src/runtime/batch-input.js +68 -0
  48. package/src/runtime/guest-api.js +281 -0
  49. package/src/runtime/guest-worker.js +62 -333
  50. package/src/runtime/parallel.js +41 -39
  51. package/src/runtime/program-batch.js +21 -75
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +6 -5
  55. package/src/runtime/runtime.js +77 -253
  56. package/src/runtime/worker-pool.js +91 -0
  57. package/src/shared/decode.js +22 -8
  58. package/src/shared/image-worker.js +30 -0
  59. package/src/shared/image.js +78 -0
  60. package/src/shared/png.js +57 -0
  61. package/src/shared/result.js +77 -0
  62. package/src/shared/syntax-context.js +61 -3
  63. package/src/ui/host-render.js +104 -0
  64. package/src/ui/progress.js +51 -0
  65. package/src/ui/render.js +21 -421
  66. package/src/ui/trace.js +277 -0
@@ -1,8 +1,9 @@
1
+ import {pickEvidence} from "./evidence-rank.js";
2
+ import {pendingInScope,overlaySearchEntry} from './search-files.js';
1
3
  import * as fs from "node:fs/promises";
2
4
  import * as path from "node:path";
3
5
  import { WorkspaceIndex } from "./repo-index.js";
4
- import { tokenizeQuery, scorePathTopology, stem } from "./snap.js";
5
- import { isTestPath } from "../fs/workspace.js";
6
+ import { tokenizeQuery, scorePathTopology, stem } from "./query.js";
6
7
 
7
8
  // Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
8
9
  // The codebase is the interaction history H; declared spans are the context units;
@@ -38,15 +39,7 @@ const IDENT = /[A-Za-z_$][\w$]*/g;
38
39
  // Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
39
40
  const RELATION_WORDS = new Set(["calls", "called", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
40
41
 
41
- const HUB_FRACTION = 0.25;
42
-
43
- const HUB_MIN = 8;
44
-
45
- export { stem } from "./snap.js";
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
- }
42
+ export { stem } from "./query.js";
50
43
 
51
44
  /** eq.6: query profile with subjects, keywords/stems, answer type, test/doc flags, route. */
52
45
  export function profileQuery(query) {
@@ -81,314 +74,6 @@ function spansOf(entry, filePath, maxSpanLines) {
81
74
  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 }));
82
75
  }
83
76
 
84
- function spanLines(span) {
85
- return span.lower.slice(span.start - 1, span.end);
86
- }
87
-
88
- // ---- eq.3 / eq.4: entity–context graph over candidate spans ----
89
-
90
- function countSpanEntities(span, entityNames) {
91
- const counts = new Map();
92
- let total = 0;
93
- const { idents } = span.lines;
94
-
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
-
103
- return { counts, total };
104
- }
105
-
106
- function weightsFromCounts(counts, total, entitySpans, spanId) {
107
- const weights = new Map();
108
-
109
- for (const [e, c] of counts) {
110
- weights.set(e, c / total); // eq.4
111
-
112
- if (!entitySpans.has(e)) entitySpans.set(e, new Set());
113
- entitySpans.get(e).add(spanId);
114
- }
115
-
116
- return weights;
117
- }
118
-
119
- function hubEntities(entitySpans, spans) {
120
- // Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
121
- const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
122
- const hubs = new Set();
123
-
124
- for (const [entity, ids] of entitySpans) {
125
- if (ids.size > hubLimit) hubs.add(entity);
126
- }
127
-
128
- return hubs;
129
- }
130
-
131
- function buildGraph(spans) {
132
- const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
133
- const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
134
- const entitySpans = new Map(); // entity → Set(span.id)
135
-
136
- for (const span of spans) {
137
- const { counts, total } = countSpanEntities(span, entityNames);
138
- spanEntities.set(span.id, weightsFromCounts(counts, total, entitySpans, span.id));
139
- }
140
-
141
- return { entityNames, spanEntities, entitySpans, hubs: hubEntities(entitySpans, spans), byId: new Map(spans.map((s) => [s.id, s])) };
142
- }
143
-
144
- // ---- eq.8 / eq.9: entity activation and one propagation step ----
145
-
146
- function lexicalSim(a, b) {
147
- const ta = new Set(splitIdentifier(a));
148
- const tb = new Set(splitIdentifier(b));
149
-
150
- if (ta.size === 0 || tb.size === 0) return 0;
151
- let inter = 0;
152
-
153
- for (const t of ta) if (tb.has(t)) inter++;
154
-
155
- return inter / (ta.size + tb.size - inter);
156
- }
157
-
158
- function activateEntities(profile, graph) {
159
- const eta = new Map();
160
- const anchors = profile.subjects.length ? profile.subjects : profile.stems;
161
-
162
- for (const anchor of anchors) {
163
- let best = null;
164
- let bestSim = 0;
165
-
166
- for (const e of graph.entityNames) {
167
- const sim = e.toLowerCase() === anchor.toLowerCase() ? 1 : lexicalSim(e, anchor);
168
-
169
- if (sim > bestSim) {
170
- bestSim = sim;
171
- best = e;
172
- }
173
- }
174
-
175
- if (best && bestSim >= 0.5) eta.set(best, Math.max(eta.get(best) || 0, bestSim)); // eq.8
176
- }
177
-
178
- return eta;
179
- }
180
-
181
- function querySim(profile, lowerLine) {
182
- let hits = 0;
183
-
184
- for (const t of profile.stems) if (lowerLine.includes(t)) hits++;
185
-
186
- return profile.stems.length ? hits / profile.stems.length : 0;
187
- }
188
-
189
- /** Co-occurring entities on one query-relevant line receive act·sim(q,z)·idf (eq.9, IDF-damped). */
190
- function activateCooccurring(e, line, weight, spanId, graph, eta1) {
191
- for (const [other] of graph.spanEntities.get(spanId)) {
192
- if (other === e || graph.hubs.has(other) || !line.includes(other.toLowerCase())) continue;
193
- const idf = 1 / Math.log2(1 + graph.entitySpans.get(other).size);
194
- eta1.set(other, (eta1.get(other) || 0) + weight * idf);
195
- }
196
- }
197
-
198
- function propagateFrom(e, act, graph, profile, eta1) {
199
- const eLower = e.toLowerCase();
200
-
201
- for (const spanId of graph.entitySpans.get(e) || []) {
202
- for (const line of spanLines(graph.byId.get(spanId))) {
203
- if (!line.includes(eLower)) continue;
204
- const sim = querySim(profile, line);
205
-
206
- if (sim > 0) activateCooccurring(e, line, act * sim, spanId, graph, eta1);
207
- }
208
- }
209
- }
210
-
211
- function propagate(eta0, spans, graph, profile) {
212
- const eta1 = new Map(eta0);
213
-
214
- for (const [e, act] of eta0) propagateFrom(e, act, graph, profile, eta1);
215
-
216
- return eta1;
217
- }
218
-
219
- // ---- eq.10: personalized PageRank over spans ----
220
-
221
- function resetDistribution(spans, graph, eta, prior) {
222
- const n = spans.length;
223
- const reset = new Float64Array(n);
224
- let sum = 0;
225
-
226
- for (let i = 0; i < n; i++) {
227
- let r = prior[i];
228
-
229
- for (const [e, w] of graph.spanEntities.get(spans[i].id)) if (!graph.hubs.has(e)) r += (eta.get(e) || 0) * w;
230
- reset[i] = r;
231
- sum += r;
232
- }
233
-
234
- if (sum > 0) for (let i = 0; i < n; i++) reset[i] /= sum;
235
-
236
- return { reset, sum };
237
- }
238
-
239
- /**
240
- * Transition structure d → d' = Σ_e w(d,e)·w(d',e) over shared non-hub entities plus 0.5 per in-file
241
- * neighbour (Edd). Kept factored through the entity layer so an iteration costs O(nnz), never O(n²).
242
- */
243
- function transitionStructure(spans, graph) {
244
- const n = spans.length;
245
- const entities = [];
246
-
247
- for (const [entity, ids] of graph.entitySpans) {
248
- if (ids.size >= 2 && !graph.hubs.has(entity)) entities.push(entity);
249
- }
250
-
251
- const eIndex = new Map(entities.map((e, i) => [e, i]));
252
-
253
- const spanTerms = spans.map((s) => {
254
- const terms = [];
255
-
256
- for (const [e, w] of graph.spanEntities.get(s.id)) if (eIndex.has(e)) terms.push([eIndex.get(e), w]);
257
-
258
- return terms;
259
- });
260
-
261
- const entityMass = new Float64Array(entities.length);
262
-
263
- for (let i = 0; i < n; i++) for (const [ei, w] of spanTerms[i]) entityMass[ei] += w;
264
- const neighbours = spans.map((s, i) => [i - 1, i + 1].filter((j) => j >= 0 && j < n && spans[j].path === s.path));
265
- const outWeight = new Float64Array(n);
266
-
267
- for (let i = 0; i < n; i++) {
268
- let out = 0.5 * neighbours[i].length;
269
-
270
- for (const [ei, w] of spanTerms[i]) out += w * (entityMass[ei] - w);
271
- outWeight[i] = out;
272
- }
273
-
274
- return { spanTerms, entityCount: entities.length, neighbours, outWeight };
275
- }
276
-
277
- function pushStep(pi, next, acc, structure, gamma) {
278
- const { spanTerms, neighbours, outWeight } = structure;
279
- let dangling = 0;
280
-
281
- for (let i = 0; i < pi.length; i++) {
282
- if (outWeight[i] === 0) {
283
- dangling += pi[i];
284
- continue;
285
- }
286
-
287
- const flow = (gamma * pi[i]) / outWeight[i];
288
-
289
- for (const [ei, w] of spanTerms[i]) {
290
- acc[ei] += flow * w;
291
- next[i] -= flow * w * w; // remove the d → d self term
292
- }
293
-
294
- for (const j of neighbours[i]) next[j] += flow * 0.5;
295
- }
296
-
297
- return dangling;
298
- }
299
-
300
- function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
301
- const n = spans.length;
302
- const { reset, sum } = resetDistribution(spans, graph, eta, prior);
303
-
304
- if (sum === 0) return reset;
305
- const structure = transitionStructure(spans, graph);
306
- const acc = new Float64Array(structure.entityCount);
307
- let pi = Float64Array.from(reset);
308
-
309
- for (let iter = 0; iter < pprIterations; iter++) {
310
- const next = new Float64Array(n);
311
- acc.fill(0);
312
- const dangling = pushStep(pi, next, acc, structure, gamma);
313
-
314
- for (let i = 0; i < n; i++) {
315
- for (const [ei, w] of structure.spanTerms[i]) next[i] += acc[ei] * w;
316
- next[i] += (1 - gamma) * reset[i] + gamma * dangling * reset[i]; // dangling mass follows the reset
317
- }
318
-
319
- pi = next;
320
- }
321
-
322
- return pi;
323
- }
324
-
325
- // ---- eq.11: hierarchical view (file → span → line) ----
326
-
327
- function nameDefinitionScore(span, profile, usage) {
328
- if (usage) return 0; // eq.15 Rank_ϕ: a usage question is answered by callers, not the definer
329
- const nameTokens = splitIdentifier(span.name || "");
330
- let def = 0;
331
-
332
- for (const t of profile.stems) if (nameTokens.some((n) => n.startsWith(t))) def += 40;
333
-
334
- return def;
335
- }
336
-
337
- function mentionScore(span, profile, usage, skipDeclaration) {
338
- const perHit = usage ? 15 : 5;
339
- let mentions = 0;
340
- let bestLine = span.start;
341
- let bestHits = 0;
342
- const lines = spanLines(span);
343
-
344
- for (let i = skipDeclaration ? 1 : 0; i < lines.length; i++) {
345
- let hits = 0;
346
-
347
- for (const t of profile.stems) if (lines[i].includes(t)) hits++;
348
-
349
- if (hits > bestHits) {
350
- bestHits = hits;
351
- bestLine = span.start + i;
352
- }
353
-
354
- mentions += hits * hits * perHit; // several query stems on one line is strong evidence
355
- }
356
-
357
- return { mentions, bestLine };
358
- }
359
-
360
- function hierarchicalScores(spans, fileScores, profile) {
361
- const usage = profile.answerType === "usage";
362
-
363
- return spans.map((span) => {
364
- const definesSubject = profile.subjects.includes(span.name);
365
- const def = nameDefinitionScore(span, profile, usage);
366
- const { mentions, bestLine } = mentionScore(span, profile, usage, usage && definesSubject);
367
- span.bestLine = bestLine;
368
- span.support = def + mentions; // span-level lexical evidence; file-level bonuses do not count
369
- const fileScore = Math.max(0, fileScores.get(span.path) || 0);
370
-
371
- return fileScore / 2 + def + Math.min(mentions, usage ? 200 : 120) + (span.isExport ? 10 : 0);
372
- });
373
- }
374
-
375
- // ---- eq.12 / eq.13 ----
376
-
377
- function normalize(scores) {
378
- let min = Infinity;
379
- let max = -Infinity;
380
-
381
- for (const s of scores) {
382
- if (s < min) min = s;
383
-
384
- if (s > max) max = s;
385
- }
386
-
387
- if (!(max > min)) return scores.map(() => 1);
388
-
389
- return scores.map((s) => (s - min) / (max - min));
390
- }
391
-
392
77
  // ---- candidate files (boundary + topology + entity hits) ----
393
78
 
394
79
  function topologyScored(files, profile) {
@@ -440,48 +125,6 @@ function candidateFiles(files, profile, index, limit, overlayText) {
440
125
  return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
441
126
  }
442
127
 
443
- // ---- eq.14 / eq.15 ----
444
-
445
- function bridgesFor(i, spans, graph, fused, definers, chosen) {
446
- const byId = graph.byId;
447
- const bridges = [];
448
-
449
- for (const [e, w] of graph.spanEntities.get(spans[i].id)) {
450
- const defId = definers.get(e);
451
-
452
- if (!defId || graph.hubs.has(e) || chosen.has(defId) || defId === spans[i].id || w < 0.15) continue;
453
- const definer = byId.get(defId);
454
-
455
- if (definer.end - definer.start < 2) continue; // one-line helpers add no understanding
456
- bridges.push({ id: defId, w: w * fused[definer.index0] });
457
- }
458
-
459
- return bridges.sort((a, b) => b.w - a.w).slice(0, 2);
460
- }
461
-
462
- function closure(main, spans, graph, fused, k) {
463
- spans.forEach((s, i) => (s.index0 = i));
464
- const chosen = new Map(main.map((i) => [spans[i].id, { i, why: "main" }]));
465
- const definers = new Map();
466
-
467
- for (const s of spans) if (s.name) definers.set(s.name, s.id);
468
- const neighboursOf = (i) => [i - 1, i + 1].filter((j) => j >= 0 && j < spans.length && spans[j].path === spans[i].path);
469
-
470
- for (const i of main) {
471
- // Ng: spans that define identifiers this span uses (relational bridges).
472
- for (const b of bridgesFor(i, spans, graph, fused, definers, chosen)) chosen.set(b.id, { i: graph.byId.get(b.id).index0, why: "bridge" });
473
-
474
- // Nh: in-file neighbours that still carry query signal.
475
- for (const j of neighboursOf(i)) {
476
- if (fused[j] > 0.2 && !chosen.has(spans[j].id)) chosen.set(spans[j].id, { i: j, why: "neighbor" });
477
- }
478
- }
479
-
480
- const supports = [...chosen.values()].filter((c) => c.why !== "main").sort((a, b) => fused[b.i] - fused[a.i]).slice(0, k);
481
-
482
- return [...main.map((i) => ({ i, why: "main" })), ...supports];
483
- }
484
-
485
128
  function render(spans, picks, fused, opts, root) {
486
129
  const out = [];
487
130
  let budget = opts.maxChars;
@@ -505,16 +148,16 @@ function render(spans, picks, fused, opts, root) {
505
148
  const lastLine = span.start + text.split("\n").length - 1;
506
149
  const truncated = lastLine < span.sourceEnd;
507
150
  budget -= text.length;
508
- out.push({
151
+ const rendered = {
509
152
  path: path.relative(root, span.path) || span.path,
510
153
  lines: [span.start, lastLine],
511
- truncated: truncated || undefined,
512
- nextOffset: truncated ? lastLine + 1 : undefined,
513
154
  name: span.name,
514
155
  kind: span.kind,
515
156
  why,
516
157
  text,
517
- });
158
+ };
159
+ if (truncated) { rendered.truncated = true; rendered.nextOffset = lastLine+1; }
160
+ out.push(rendered);
518
161
 
519
162
  if (budget <= 0) break;
520
163
  }
@@ -522,14 +165,6 @@ function render(spans, picks, fused, opts, root) {
522
165
  return out;
523
166
  }
524
167
 
525
- function stagedInRoot(searchRoot, pendingPaths) {
526
- return pendingPaths.filter(file => {
527
- const relative = path.relative(searchRoot, file);
528
-
529
- return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
530
- });
531
- }
532
-
533
168
  function statCatch(error) {
534
169
  if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
535
170
  throw error;
@@ -547,26 +182,18 @@ async function diskFilesAt(searchRoot, index) {
547
182
 
548
183
  async function listedFiles(root, searchDir, pendingPaths, index) {
549
184
  const searchRoot = path.resolve(searchDir || root);
550
- const files = [...new Set([...await diskFilesAt(searchRoot, index), ...stagedInRoot(searchRoot, pendingPaths)])];
185
+ const files = [...new Set([...await diskFilesAt(searchRoot, index), ...pendingInScope(searchRoot, pendingPaths)])];
551
186
 
552
187
  if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
553
188
 
554
189
  return files;
555
190
  }
556
191
 
557
- function overlayEntry(f, overlayText, index) {
558
- const pending = overlayText(f);
559
-
560
- return pending === undefined
561
- ? index.entry(f)
562
- : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(f, pending) : null;
563
- }
564
-
565
192
  function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
566
193
  const spans = [];
567
194
 
568
195
  for (const f of chosenFiles) {
569
- const entry = overlayEntry(f, overlayText, index);
196
+ const entry = overlaySearchEntry(index, f, overlayText);
570
197
 
571
198
  if (!entry) continue;
572
199
  spans.push(...spansOf(entry, f, maxSpanLines));
@@ -592,47 +219,6 @@ function usageSpans(spans, profile, maxSpanLines) {
592
219
  });
593
220
  }
594
221
 
595
- function fuseScores(profile, graphNorm, hierNorm, rho) {
596
- const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
597
-
598
- return primary.map((p, i) => rho * p + (1 - rho) * secondary[i]); // eq.13
599
- }
600
-
601
- function spanAdmissible(span, profile) {
602
- const p = span.path;
603
- const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
604
-
605
- return span.support > 0
606
- && (profile.flags.wantsTest || !isTestPath(p))
607
- && (profile.flags.wantsDoc || !isDoc);
608
- }
609
-
610
- function compareFused(spans, fused) {
611
- return (a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start;
612
- }
613
-
614
- function dampUsageDefiners(spans, profile, fused, admissible) {
615
- const usage = profile.answerType === "usage";
616
-
617
- for (const i of admissible) {
618
- if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
619
- }
620
- }
621
-
622
- function pickEvidence(spans, fileScores, profile, opts) {
623
- const graph = buildGraph(spans);
624
- const hierNorm = normalize(hierarchicalScores(spans, fileScores, profile));
625
- const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
626
- const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
627
- const fused = fuseScores(profile, normalize([...pi]), hierNorm, opts.rho);
628
- // eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
629
- const admissible = spans.flatMap((span, i) => spanAdmissible(span, profile) ? [i] : []);
630
- dampUsageDefiners(spans, profile, fused, admissible);
631
- const main = admissible.sort(compareFused(spans, fused)).slice(0, opts.k);
632
-
633
- return { picks: closure(main, spans, graph, fused, opts.k), fused };
634
- }
635
-
636
222
  /**
637
223
  * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
638
224
  * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
@@ -0,0 +1,71 @@
1
+ import * as path from 'node:path';
2
+ import {isString} from '../shared/decode.js';
3
+ import {isTestPath} from '../fs/workspace.js';
4
+
5
+ const STOP_WORDS = new Set([
6
+ "the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
7
+ "by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
8
+ "file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
9
+ ]);
10
+
11
+ const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
12
+
13
+ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
14
+
15
+ const MAX_NEEDLE_CHARS = 128;
16
+
17
+ /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
18
+ export function stem(token) {
19
+ if (token.length < 5) return token;
20
+
21
+ return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
22
+ }
23
+
24
+ export function tokenizeQuery(query) {
25
+ if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
26
+ const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
27
+
28
+ return {
29
+ tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
30
+ wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
31
+ wantsType: words.some(word => ["type", "types", "interface", "interfaces", "schema", "schemas"].includes(word)),
32
+ wantsDoc: words.some(word => ["doc", "docs", "documentation", "readme"].includes(word)),
33
+ };
34
+ }
35
+
36
+ function tokenPathScore(base, words, normalized, tokens) {
37
+ let score = 0;
38
+
39
+ for (const token of tokens) {
40
+ if (base === token || base.startsWith(token + ".")) score += 60;
41
+ else if (base.includes(token)) score += 30;
42
+ else if (words.includes(token)) score += 15;
43
+ else if (normalized.includes(token)) score += 5;
44
+ }
45
+
46
+ return score;
47
+ }
48
+
49
+ function topologyPenalty(normalized, flags) {
50
+ const parts = normalized.split("/");
51
+
52
+ if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
53
+ const test = isTestPath(normalized);
54
+
55
+ if (test && !flags.wantsTest) return -50;
56
+ if (!test && flags.wantsTest) return -20;
57
+ }
58
+
59
+ export function scorePathTopology(filePath, tokens, flags) {
60
+ const normalized = filePath.replaceAll("\\", "/").toLowerCase();
61
+ const penalty = topologyPenalty(normalized, flags);
62
+
63
+ if (penalty !== undefined) return penalty;
64
+ const ext = path.extname(normalized);
65
+ let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
66
+
67
+ if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
68
+
69
+ return score + tokenPathScore(path.basename(normalized), normalized.split(/[^a-zA-Z0-9]+/), normalized, tokens);
70
+ }
71
+ export { SOURCE_EXT, MAX_NEEDLE_CHARS };