pi-supernova 0.6.0 → 0.7.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 (49) hide show
  1. package/README.md +27 -3
  2. package/docs/CHANGELOG.md +104 -0
  3. package/docs/TOKEN_COSTS.md +13 -5
  4. package/index.js +120 -79
  5. package/package.json +1 -1
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +28 -222
  15. package/src/bridge/host-bridge.js +113 -1668
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -198
  18. package/src/context/evidence.js +140 -76
  19. package/src/context/fuzzy.js +42 -24
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +23 -18
  22. package/src/context/repo-index.js +206 -170
  23. package/src/context/search.js +157 -77
  24. package/src/context/snap.js +240 -136
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +14 -5
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +12 -8
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +94 -50
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +205 -175
  37. package/src/fs/workspace.js +119 -108
  38. package/src/output/bottleneck.js +195 -116
  39. package/src/output/format.js +101 -67
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +289 -292
  42. package/src/runtime/parallel.js +97 -64
  43. package/src/runtime/program-batch.js +178 -69
  44. package/src/runtime/reference.js +15 -14
  45. package/src/runtime/runtime.js +327 -187
  46. package/src/shared/decode.js +58 -36
  47. package/src/ui/omp-frame.js +59 -42
  48. package/src/ui/render-measure.js +51 -29
  49. package/src/ui/render.js +241 -145
@@ -0,0 +1,35 @@
1
+ import { isString } from "../shared/decode.js";
2
+
3
+ /** Ordered permission rules. First decisive answer wins. */
4
+ export function toolIsCallable(name, env) {
5
+ if (name === "supernova" || env.excluded.has(name)) return false;
6
+ if (env.sessionInvalid()) return false;
7
+ if (env.nativeOwned(name)) return true;
8
+ if (env.hostSession) return env.evalAllows(name);
9
+
10
+ return env.listed(name);
11
+ }
12
+
13
+ function isArgvOwned(name, args) {
14
+ return name === "bash" && Array.isArray(args?.args) && process.platform !== "win32"
15
+ && args.args.length === Object.keys(args.args).length && args.args.every(isString);
16
+ }
17
+
18
+ function hostExecutor(name, env) {
19
+ const delegated = env.hostTool(name);
20
+
21
+ if (delegated) return { exec: delegated.execute.bind(delegated), delegated };
22
+ if (env.hostSession) return { exec: undefined, delegated };
23
+
24
+ return { exec: env.executors.get(name), delegated };
25
+ }
26
+
27
+ export function resolveInvokeTarget(name, args, env) {
28
+ const argvOwned = isArgvOwned(name, args);
29
+ const { exec, delegated } = hostExecutor(name, env);
30
+
31
+ if (exec && !argvOwned) return { kind: "override", exec, delegated, argvOwned: false };
32
+ if (env.natives[name]) return { kind: "native", native: env.natives[name], argvOwned };
33
+
34
+ return { kind: "unknown" };
35
+ }
@@ -1,199 +1,2 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
- import { homedir } from "node:os";
4
- import { loadConfig } from "../config/config.js";
5
- import { isString } from "../shared/decode.js";
6
- import { createHostBridge } from "./host-bridge.js";
7
- import { buildPatchDiff } from "../fs/diff.js";
8
- import { createNativeScheduler } from "../runtime/parallel.js";
9
- import { truncateChars } from "../output/format.js";
10
- import { clearPathCache, resolveWorkspacePath } from "../fs/workspace.js";
11
-
1
+ /** Native command names. Host registration lives in createHostBridge. */
12
2
  export const NATIVE_NAMES = Object.freeze(["read", "edit", "write", "bash"]);
13
-
14
- function userPath(cwd, input) {
15
- if (!isString(input) || !input.trim()) throw new Error("path must be a non-empty string");
16
- const value = input.trim().replace(/^@/, "");
17
-
18
- return path.resolve(cwd, value === "~" ? homedir() : value.startsWith("~/") ? path.join(homedir(), value.slice(2)) : value);
19
- }
20
-
21
- function boundText(result, maxChars) {
22
- const total = result.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
23
- const truncated = total > maxChars;
24
- const notice = truncated ? truncateChars("\n[Truncated: read files individually or narrow the line range/question.]", maxChars, "read-result").text : "";
25
- let remaining = maxChars - notice.length;
26
-
27
- const content = result.content.map(block => {
28
- if (block.type !== "text") return block; // Images remain image attachments.
29
- const bounded = truncateChars(block.text, remaining, "read-result");
30
- remaining -= bounded.text.length;
31
-
32
- return { ...block, text: bounded.text };
33
- });
34
-
35
- if (notice) content.push({ type: "text", text: notice });
36
- const details = { ...result.details };
37
-
38
- if (truncated) details.outputTruncated = true;
39
-
40
- return { ...result, content, details };
41
- }
42
-
43
- /** Familiar Pi tool definitions with Supernova's source engine and atomic VFS. */
44
- export function registerNativeTools(pi, host, config = loadConfig()) {
45
- let cwd = process.cwd();
46
- const base = createHostBridge({ pi: null, config: { ...config, seenWindow: 0 }, getCwd: () => cwd });
47
- const scheduler = createNativeScheduler();
48
-
49
- const factories = {
50
- read: host.createReadToolDefinition,
51
- edit: host.createEditToolDefinition,
52
- write: host.createWriteToolDefinition,
53
- bash: host.createBashToolDefinition,
54
- };
55
-
56
- function settingsFor(ctx) {
57
- try {
58
- return host.SettingsManager?.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted?.() === true });
59
- } catch {
60
- return undefined;
61
- }
62
- }
63
-
64
- async function readOne(args, signal, ctx, bridge, id) {
65
- const target = userPath(ctx.cwd, args.path);
66
- let stat;
67
-
68
- try { stat = await fs.stat(target); }
69
- catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; }
70
-
71
- signal?.throwIfAborted();
72
- const image = /\.(png|jpe?g|gif|webp|bmp)$/i.test(target);
73
-
74
- if (stat?.isFile() && args.about === undefined && args.query === undefined && args.resolve === undefined && args.outline === undefined && args.evidence === undefined && args.json === undefined && args.complete === undefined) {
75
- // Pi retains image handling, full text, line windows, truncation metadata,
76
- // and actionable continuation offsets. Do not summarize or dedupe these.
77
- return factories.read(ctx.cwd, { autoResizeImages: image ? settingsFor(ctx)?.getImageAutoResize() : undefined }).execute(id, { ...args, path: target }, signal, undefined, ctx);
78
- }
79
-
80
- return boundText(await bridge.natives.read({ ...args, path: stat ? target : args.path }, signal), config.maxCallResultChars);
81
- }
82
-
83
- async function execute(name, id, args, signal, onUpdate, context) {
84
- const ctx = { ...context, cwd: isString(context?.cwd) && context.cwd ? context.cwd : cwd };
85
- const bridge = base.fork({ getCwd: () => ctx.cwd });
86
- bridge.bindCallContext(ctx, signal);
87
- signal?.throwIfAborted();
88
-
89
- try {
90
- if (name === "read") {
91
- if (!Array.isArray(args.path)) return await readOne(args, signal, ctx, bridge, id);
92
-
93
- if (args.path.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
94
-
95
- if (args.path.length > 64) throw new Error("read path arrays support at most 64 entries; use smaller batches");
96
- const groups = [], errors = [];
97
-
98
- // Bound fan-out even when a model submits a large path array.
99
- for (let offset = 0; offset < args.path.length; offset += 8) {
100
- const results = await Promise.all(args.path.slice(offset, offset + 8).map(async file => {
101
- try {
102
- const result = await readOne({ ...args, path: file }, signal, ctx, bridge, id);
103
-
104
- return { content: [{ type: "text", text: "File: " + file }, ...result.content] };
105
- } catch (error) {
106
- signal?.throwIfAborted();
107
- errors.push({ path: file, message: error.message });
108
-
109
- return { content: [{ type: "text", text: "[read error: " + file + "] " + error.message }] };
110
- }
111
- }));
112
-
113
- groups.push(...results);
114
- }
115
-
116
- const content = [];
117
- let remaining = config.maxCallResultChars, outputTruncated = false;
118
-
119
- for (let i = 0; i < groups.length; i++) {
120
- const bounded = boundText(groups[i], Math.floor(remaining / (groups.length - i)));
121
- remaining -= bounded.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
122
- outputTruncated ||= bounded.details.outputTruncated === true;
123
- content.push(...bounded.content);
124
- }
125
-
126
- return { content, details: { batch: true, count: args.path.length, errors, outputTruncated } };
127
- }
128
-
129
- if (name === "bash") {
130
- const settings = settingsFor(ctx);
131
-
132
- try { return await factories.bash(ctx.cwd, { shellPath: settings?.getShellPath(), commandPrefix: settings?.getShellCommandPrefix() }).execute(id, args, signal, onUpdate, ctx); }
133
- finally { bridge.invalidateFiles(); }
134
- }
135
-
136
- clearPathCache();
137
- const target = await resolveWorkspacePath(ctx.cwd, userPath(ctx.cwd, args.path), name);
138
- const ops = bridge.fileOperations;
139
- let before, after;
140
-
141
- const tool = factories[name](ctx.cwd, { operations: {
142
- ...ops,
143
- async readFile(file) { const buffer = await ops.readFile(file); before = buffer.toString("utf8");
144
-
145
- return buffer; },
146
- async writeFile(file, content) { await ops.writeFile(file, content); after = content; },
147
- } });
148
-
149
- // Pi's edit/write implementations hold its shared canonical per-file queue
150
- // over the entire mutation, including our atomic replacement operation.
151
- const result = await tool.execute(id, { ...args, path: target }, signal, onUpdate, ctx);
152
-
153
- if (name === "edit" && before !== undefined && after !== undefined && result.details?.patch) {
154
- const summary = await bridge.summarizeEdit(target, before, after, buildPatchDiff(target, result.details.patch));
155
- result.content = [{ type: "text", text: summary }];
156
- }
157
-
158
- return result;
159
- } finally { bridge.close(); }
160
- }
161
-
162
- for (const name of NATIVE_NAMES) {
163
- const definition = factories[name](cwd);
164
-
165
- const tool = {
166
- ...definition,
167
- execute(id, args, signal, onUpdate, ctx) {
168
- return scheduler.schedule(name, () => execute(name, id, args, signal, onUpdate, ctx), signal);
169
- },
170
- };
171
-
172
- if (name === "read") {
173
- tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question (at most 16 keywords). An array of up to 64 paths returns all readable files and labels individual errors. Images return as attachments up to 20 MiB each; at most 16 image attachments are returned.";
174
- tool.parameters = { ...definition.parameters, properties: {
175
- ...definition.parameters.properties,
176
- path: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }], description: "File, directory, source question, or up to 64 paths to read" },
177
- about: { type: "string", description: "Focus on this question or symbol (at most 16 keywords); source selection reports uncertainty instead of guessing" },
178
- query: { type: "string", description: "Source question; optional path scopes the search" },
179
- outline: { type: "boolean" },
180
- evidence: { type: "boolean" },
181
- resolve: { type: "boolean" },
182
- complete: { type: "boolean" },
183
- json: { anyOf: [{ type: "boolean" }, { type: "string" }, { type: "array", items: { type: "string" }, maxItems: 64 }] },
184
- } };
185
- tool.promptGuidelines = [...(definition.promptGuidelines || []), "read can find source from a symbol or question; check its selection status before choosing a file. Plain file reads preserve full text within the stated limits."];
186
- }
187
-
188
- pi.registerTool(tool);
189
- }
190
-
191
- pi.on("session_start", (_event, ctx) => {
192
- cwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
193
- base.invalidateFiles();
194
- });
195
- pi.registerCommand("supernova", {
196
- description: "Show Supernova native runtime status",
197
- handler: async (_args, ctx) => ctx.ui.notify("Supernova runtime: read, edit, write, bash; " + scheduler.stats.calls + " calls, " + scheduler.stats.readWaves + " read waves, peak " + scheduler.stats.peakParallelReads + " parallel reads. Mutations are ordered; plain reads are not summarized or deduplicated.", "info"),
198
- });
199
- }
@@ -87,36 +87,36 @@ function spanLines(span) {
87
87
 
88
88
  // ---- eq.3 / eq.4: entity–context graph over candidate spans ----
89
89
 
90
- function buildGraph(spans) {
91
- const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
92
- const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
93
- const entitySpans = new Map(); // entity → Set(span.id)
94
-
95
- for (const span of spans) {
96
- const counts = new Map();
97
- let total = 0;
98
- const { idents } = span.lines;
99
-
100
- for (let li = span.start - 1; li < span.end; li++) {
101
- for (const word of idents[li]) {
102
- if (!entityNames.has(word)) continue;
103
- counts.set(word, (counts.get(word) || 0) + 1);
104
- total += 1;
105
- }
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;
106
100
  }
101
+ }
107
102
 
108
- const weights = new Map();
103
+ return { counts, total };
104
+ }
109
105
 
110
- for (const [e, c] of counts) {
111
- weights.set(e, c / total); // eq.4
106
+ function weightsFromCounts(counts, total, entitySpans, spanId) {
107
+ const weights = new Map();
112
108
 
113
- if (!entitySpans.has(e)) entitySpans.set(e, new Set());
114
- entitySpans.get(e).add(span.id);
115
- }
109
+ for (const [e, c] of counts) {
110
+ weights.set(e, c / total); // eq.4
116
111
 
117
- spanEntities.set(span.id, weights);
112
+ if (!entitySpans.has(e)) entitySpans.set(e, new Set());
113
+ entitySpans.get(e).add(spanId);
118
114
  }
119
115
 
116
+ return weights;
117
+ }
118
+
119
+ function hubEntities(entitySpans, spans) {
120
120
  // Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
121
121
  const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
122
122
  const hubs = new Set();
@@ -125,7 +125,20 @@ function buildGraph(spans) {
125
125
  if (ids.size > hubLimit) hubs.add(entity);
126
126
  }
127
127
 
128
- return { entityNames, spanEntities, entitySpans, hubs, byId: new Map(spans.map((s) => [s.id, s])) };
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])) };
129
142
  }
130
143
 
131
144
  // ---- eq.8 / eq.9: entity activation and one propagation step ----
@@ -378,7 +391,7 @@ function normalize(scores) {
378
391
 
379
392
  // ---- candidate files (boundary + topology + entity hits) ----
380
393
 
381
- function candidateFiles(files, profile, index, limit, overlayText) {
394
+ function topologyScored(files, profile) {
382
395
  const scored = [];
383
396
 
384
397
  for (const f of files) {
@@ -388,27 +401,41 @@ function candidateFiles(files, profile, index, limit, overlayText) {
388
401
  }
389
402
 
390
403
  scored.sort((a, b) => b.s - a.s);
391
- const chosen = new Set();
392
- const anchors = (profile.subjects.length ? profile.subjects : profile.stems).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
393
404
 
394
- const pendingHits = files.filter(file => {
405
+ return scored;
406
+ }
407
+
408
+ function pendingAnchorHits(files, anchors, overlayText) {
409
+ return files.filter(file => {
395
410
  const pending = overlayText(file);
396
411
 
397
412
  return pending !== undefined && Buffer.byteLength(pending, "utf8") <= 512 * 1024 && anchors.some(anchor => pending.toLowerCase().includes(anchor));
398
413
  });
414
+ }
399
415
 
400
- const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
401
-
416
+ function chooseByHits(hits, profile, limit, chosen) {
402
417
  for (const f of hits) {
403
418
  if (chosen.size >= limit) break;
404
419
 
405
420
  if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
406
421
  }
422
+ }
407
423
 
424
+ function fillFromScored(scored, limit, chosen) {
408
425
  for (const { f } of scored) {
409
426
  if (chosen.size >= limit) break;
410
427
  chosen.add(f);
411
428
  }
429
+ }
430
+
431
+ function candidateFiles(files, profile, index, limit, overlayText) {
432
+ const scored = topologyScored(files, profile);
433
+ const chosen = new Set();
434
+ const anchors = (profile.subjects.length ? profile.subjects : profile.stems).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
435
+ const pendingHits = pendingAnchorHits(files, anchors, overlayText);
436
+ const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
437
+ chooseByHits(hits, profile, limit, chosen);
438
+ fillFromScored(scored, limit, chosen);
412
439
 
413
440
  return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
414
441
  }
@@ -495,77 +522,114 @@ function render(spans, picks, fused, opts, root) {
495
522
  return out;
496
523
  }
497
524
 
498
- /**
499
- * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
500
- * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
501
- */
502
- export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
503
- const opts = { ...EVIDENCE_DEFAULTS, ...options };
504
- const profile = profileQuery(query);
505
-
506
- if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
507
- const searchRoot = path.resolve(searchDir || root);
508
-
509
- const staged = pendingPaths.filter(file => {
525
+ function stagedInRoot(searchRoot, pendingPaths) {
526
+ return pendingPaths.filter(file => {
510
527
  const relative = path.relative(searchRoot, file);
511
528
 
512
529
  return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
513
530
  });
531
+ }
514
532
 
515
- const rootStat = await fs.stat(searchRoot).catch(error => {
516
- if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
517
- throw error;
518
- });
519
- const diskFiles = rootStat?.isFile() ? [searchRoot] : rootStat ? await index.files(searchRoot) : [];
520
- const files = [...new Set([...diskFiles, ...staged])];
533
+ function statCatch(error) {
534
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
535
+ throw error;
536
+ }
537
+
538
+ async function diskFilesAt(searchRoot, index) {
539
+ const rootStat = await fs.stat(searchRoot).catch(statCatch);
540
+
541
+ if (rootStat?.isFile()) return [searchRoot];
542
+
543
+ if (rootStat) return index.files(searchRoot);
544
+
545
+ return [];
546
+ }
547
+
548
+ async function listedFiles(root, searchDir, pendingPaths, index) {
549
+ const searchRoot = path.resolve(searchDir || root);
550
+ const files = [...new Set([...await diskFilesAt(searchRoot, index), ...stagedInRoot(searchRoot, pendingPaths)])];
521
551
 
522
552
  if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
523
553
 
524
- const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
554
+ return files;
555
+ }
556
+
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
+ function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
525
566
  const spans = [];
526
567
 
527
568
  for (const f of chosenFiles) {
528
- const pending = overlayText(f);
529
- const entry = pending === undefined
530
- ? index.entry(f)
531
- : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(f, pending) : null;
569
+ const entry = overlayEntry(f, overlayText, index);
532
570
 
533
571
  if (!entry) continue;
534
- spans.push(...spansOf(entry, f, opts.maxSpanLines));
572
+ spans.push(...spansOf(entry, f, maxSpanLines));
535
573
  }
536
574
 
537
- if (spans.length === 0) return { route: profile.route, spans: [] };
538
-
539
- const graph = buildGraph(spans);
540
- const hier = hierarchicalScores(spans, fileScores, profile);
541
- const hierNorm = normalize(hier);
542
- const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
543
- const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
544
- const graphNorm = normalize([...pi]);
575
+ return spans;
576
+ }
545
577
 
578
+ function fuseScores(profile, graphNorm, hierNorm, rho) {
546
579
  const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
547
- const fused = primary.map((p, i) => opts.rho * p + (1 - opts.rho) * secondary[i]); // eq.13
548
580
 
549
- // eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
550
- const usage = profile.answerType === "usage";
581
+ return primary.map((p, i) => rho * p + (1 - rho) * secondary[i]); // eq.13
582
+ }
551
583
 
552
- const admissible = spans.flatMap((span, i) => {
553
- const p = span.path;
554
- const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
584
+ function spanAdmissible(span, profile) {
585
+ const p = span.path;
586
+ const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
555
587
 
556
- return span.support > 0
557
- && (profile.flags.wantsTest || !isTestPath(p))
558
- && (profile.flags.wantsDoc || !isDoc)
559
- ? [i] : [];
560
- });
588
+ return span.support > 0
589
+ && (profile.flags.wantsTest || !isTestPath(p))
590
+ && (profile.flags.wantsDoc || !isDoc);
591
+ }
592
+
593
+ function compareFused(spans, fused) {
594
+ return (a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start;
595
+ }
596
+
597
+ function dampUsageDefiners(spans, profile, fused, admissible) {
598
+ const usage = profile.answerType === "usage";
561
599
 
562
600
  for (const i of admissible) {
563
601
  if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
564
602
  }
603
+ }
604
+
605
+ function pickEvidence(spans, fileScores, profile, opts) {
606
+ const graph = buildGraph(spans);
607
+ const hierNorm = normalize(hierarchicalScores(spans, fileScores, profile));
608
+ const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
609
+ const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
610
+ const fused = fuseScores(profile, normalize([...pi]), hierNorm, opts.rho);
611
+ // eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
612
+ const admissible = spans.flatMap((span, i) => spanAdmissible(span, profile) ? [i] : []);
613
+ dampUsageDefiners(spans, profile, fused, admissible);
614
+ const main = admissible.sort(compareFused(spans, fused)).slice(0, opts.k);
615
+
616
+ return { picks: closure(main, spans, graph, fused, opts.k), fused };
617
+ }
618
+
619
+ /**
620
+ * R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
621
+ * @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
622
+ */
623
+ export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
624
+ const opts = { ...EVIDENCE_DEFAULTS, ...options };
625
+ const profile = profileQuery(query);
565
626
 
566
- const ranked = admissible.sort((a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start);
567
- const main = ranked.slice(0, opts.k);
568
- const picks = closure(main, spans, graph, fused, opts.k);
627
+ if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
628
+ const { files: chosenFiles, fileScores } = candidateFiles(await listedFiles(root, searchDir, pendingPaths, index), profile, index, opts.maxCandidateFiles, overlayText);
629
+ const spans = collectSpans(chosenFiles, overlayText, index, opts.maxSpanLines);
630
+
631
+ if (spans.length === 0) return { route: profile.route, spans: [] };
632
+ const { picks, fused } = pickEvidence(spans, fileScores, profile, opts);
569
633
 
570
634
  return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
571
635
  }
@@ -122,13 +122,20 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
122
122
  return score;
123
123
  }
124
124
 
125
- /** Best match allowing up to maxTypos skipped needle characters. */
126
- export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
127
- const direct = matchOnce(needle, hay, caseSensitive);
125
+ function considerShorter(part, typosLeft, visit, best) {
126
+ for (let i = 0; i < part.length; i++) {
127
+ const m = visit(part.slice(0, i) + part.slice(i + 1), typosLeft - 1);
128
128
 
129
- if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
129
+ if (!m) continue;
130
+ const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
130
131
 
131
- if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
132
+ if (!best || scored.score > best.score) best = scored;
133
+ }
134
+
135
+ return best;
136
+ }
137
+
138
+ function matchWithTypos(needle, hay, maxTypos, caseSensitive) {
132
139
  const memo = new Map();
133
140
 
134
141
  const visit = (part, typosLeft) => {
@@ -139,18 +146,7 @@ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false }
139
146
 
140
147
  if (best) best = { ...best, typos: 0, exact: hay.toLowerCase() === part.toLowerCase() };
141
148
 
142
- if (typosLeft > 0) {
143
- for (let i = 0; i < part.length; i++) {
144
- const shorter = part.slice(0, i) + part.slice(i + 1);
145
- const m = visit(shorter, typosLeft - 1);
146
-
147
- if (!m) continue;
148
- const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
149
-
150
- if (!best || scored.score > best.score) best = scored;
151
- }
152
- }
153
-
149
+ if (typosLeft > 0) best = considerShorter(part, typosLeft, visit, best);
154
150
  memo.set(key, best);
155
151
 
156
152
  return best;
@@ -159,6 +155,17 @@ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false }
159
155
  return visit(needle, maxTypos);
160
156
  }
161
157
 
158
+ /** Best match allowing up to maxTypos skipped needle characters. */
159
+ export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
160
+ const direct = matchOnce(needle, hay, caseSensitive);
161
+
162
+ if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
163
+
164
+ if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
165
+
166
+ return matchWithTypos(needle, hay, maxTypos, caseSensitive);
167
+ }
168
+
162
169
  export function smartCase(query) {
163
170
  return /[A-Z]/.test(query);
164
171
  }
@@ -180,23 +187,34 @@ function distancePenalty(currentDir, candidateDir) {
180
187
  * Rank file paths for a query the fff way. paths are workspace-relative "/"-joined.
181
188
  * ctx: { frecency: Frecency, mtimeOf: (path) => sec, modified: Set(path), currentFile?: string, maxTypos }
182
189
  */
190
+ function partTypos(parts, ctx) {
191
+ return ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
192
+ }
193
+
194
+ function scoredPath(rel, parts, maxTypos, caseSensitive, ctx, currentDir) {
195
+ const matched = matchParts(parts, rel, maxTypos, caseSensitive);
196
+
197
+ if (!matched) return null;
198
+ const { base, first, exact } = matched;
199
+ const filenameStart = rel.lastIndexOf("/") + 1;
200
+ const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
201
+
202
+ return { path: rel, score: base + boosts, exact, typos: first.typos };
203
+ }
204
+
183
205
  export function rankPaths(query, paths, ctx = {}) {
184
206
  const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
185
207
 
186
208
  if (parts.length === 0 || parts.length > 16) return [];
187
209
  const caseSensitive = smartCase(query);
188
- const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
210
+ const maxTypos = partTypos(parts, ctx);
189
211
  const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
190
212
  const out = [];
191
213
 
192
214
  for (const rel of paths) {
193
- const matched = matchParts(parts, rel, maxTypos, caseSensitive);
215
+ const scored = scoredPath(rel, parts, maxTypos, caseSensitive, ctx, currentDir);
194
216
 
195
- if (!matched) continue;
196
- const { base, first, exact } = matched;
197
- const filenameStart = rel.lastIndexOf("/") + 1;
198
- const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
199
- out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
217
+ if (scored) out.push(scored);
200
218
  }
201
219
 
202
220
  out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));