pi-supernova 0.0.8 → 0.0.11
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/CHANGELOG.md +30 -0
- package/README.md +6 -2
- package/catalog.js +12 -8
- package/evidence.js +446 -0
- package/guest-worker.js +4 -3
- package/host-bridge.js +90 -29
- package/index.js +36 -4
- package/package.json +3 -1
- package/render.js +14 -4
- package/repo-index.js +179 -0
- package/snap.js +91 -108
- package/surface.js +4 -1
- package/vfs.js +7 -1
- package/workspace.js +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.0.11] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `evidence(query, {k?, path?, maxChars?})` — zero-token evidence selection over the codebase after Zero-Mem (arXiv:2607.29377), implemented 1:1 with the paper's non-generative pipeline: declared spans are the context units and identifiers the entities (eq.3), entity–span weights `w(d,e)=c(e,d)/Σc` (eq.4), file→span→line hierarchy (eq.5, eq.11), a deterministic query profile and relational/local route (eq.6–7), lexical entity alignment and one IDF-damped co-occurrence propagation step (eq.8–9), personalized PageRank `π=(1−γ)r+γPᵀπ` over spans (eq.10, γ=0.85, 10 iterations, factored through the entity layer so it is O(nnz)), per-view min-max normalisation and ρ-weighted fusion (eq.12–13, ρ=0.7), closure with definition bridges and in-file neighbours (eq.14), and calibration that filters by boundary/answer type/lexical support and ranks by type compatibility (eq.15). Returns top-K (default 5, per the paper's Top-5 ≈ Top-10 finding) verbatim source spans with path and line provenance under a 6000-char budget. Across 8 understanding questions on this repo the correct span ranks first and the result costs **68% fewer tokens** than reading the files the question spans (43,916 → 14,147). Warm latency 1–4ms.
|
|
10
|
+
- Structural surface now records column-0 `const/let/var` bindings, so module-level tables are their own spans (also sharpens `snap`).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Tool guidance: "To understand code, call `evidence(question)` and read only the returned spans; read whole files only to edit them" (Agent Zero Memory's L0→L1→L2 read discipline; Harness-of-Harness progressive disclosure).
|
|
15
|
+
|
|
16
|
+
## [0.0.10] - 2026-09-04
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- In-process workspace index (`repo-index.js`): one gitignore-aware `rg --files` per 10s window, then file text, lowercase lines, declared names, and structural surfaces are cached per path and validated by mtime. `snap`, `grep`, `glob`, and `find` are served from it without spawning (trees over 4000 files fall back to `rg`). Warm latencies: snap 14ms → 0.36ms, grep 4.8ms → 0.15ms, glob 4.7ms → 0.06ms; a warm `nova.call` is ~20µs and the program floor is ~50µs.
|
|
21
|
+
- `bash` and any on-disk write or commit invalidate the file list, so a file created by a shell command is visible to the next `glob` in the same program.
|
|
22
|
+
- Workspace-path realpath checks are cached per program (two syscalls per call before).
|
|
23
|
+
- Live card updates are coalesced to one host re-render per 40ms frame; a tight loop of calls no longer pays a TUI render per call.
|
|
24
|
+
- Fewer result tokens: `snap` returns a workspace-relative path and a 7-line context window (`►36 text`); `grep` rows are relative; `nova.search` hits drop `callable:true`; `nova.describe` omits `required:false` and the redundant `signature` line.
|
|
25
|
+
|
|
26
|
+
## [0.0.9] - 2026-09-04
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `snap` returns the defining file and line. `const x = fn(...)` call sites matched the definition regex and earned definition credit, so the busiest caller outranked the definer; definition credit now requires the declared name to contain a query token, mention credit is capped per file, and the anchor is the surface item with the most token matches (`resolveWorkspacePath`, not `getResolvedCwd`).
|
|
31
|
+
- Success cards draw a visible frame. `borderMuted` is background-level in OMP themes, so only error cards had a border; success uses `dim`.
|
|
32
|
+
- Failed calls show their error on the row instead of `done`; rows with no target show nothing.
|
|
33
|
+
- `read([...paths])` rows read `2 files: a.js, b.js` instead of a comma-joined path list.
|
|
34
|
+
|
|
5
35
|
## [0.0.8] - 2026-09-04
|
|
6
36
|
|
|
7
37
|
### Changed
|
package/README.md
CHANGED
|
@@ -46,7 +46,9 @@ async () => {
|
|
|
46
46
|
}
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read` (path or path array), `write`, `edit`, `patch`, `surface`, `snap`, `bash`, and `exec`.
|
|
49
|
+
Globals: `nova` / `tools`, `parallel`, `pipeline`, `console`, plus shorthand `read` (path or path array), `write`, `edit`, `patch`, `evidence`, `surface`, `snap`, `bash`, and `exec`.
|
|
50
|
+
|
|
51
|
+
Read discipline that keeps context small: `surface(path)` (names only) → `evidence(question)` (the spans that answer it) → `read(path, offset, limit)` only for the lines you will edit.
|
|
50
52
|
|
|
51
53
|
The returned value is rendered as a compact JS literal (unquoted keys, one item per line only when a container exceeds 120 columns) and capped at `maxReturnChars`. Strings are returned raw. This costs ~43% fewer tokens than pretty JSON — return small shaped values, not raw file dumps.
|
|
52
54
|
|
|
@@ -75,8 +77,9 @@ Multi-line commands show their first line plus a hidden-line count. Press Enter
|
|
|
75
77
|
| `nova.describe(name)` | Parameter summary on demand |
|
|
76
78
|
| `nova.call(name, args)` | Host tool or native adapter |
|
|
77
79
|
| `nova.callMany([{name,args}])` | Auto parallel wave — iterable array with `.mode` / `.results` |
|
|
80
|
+
| `nova.evidence(query, {k?, path?, maxChars?})` | Top-K source spans (path, lines, verbatim text) that answer a question — zero-token evidence selection after Zero-Mem; ~68% fewer tokens than reading the files |
|
|
78
81
|
| `nova.surface(path)` | Structural outline for a source file |
|
|
79
|
-
| `nova.snap(query, searchRoot?)` |
|
|
82
|
+
| `nova.snap(query, searchRoot?)` | Defining file (workspace-relative), line, signature, confidence, and context for a concept; served from the in-process index in well under 1ms |
|
|
80
83
|
| `nova.has(name)` | Whether a catalog or native tool is callable (sync) |
|
|
81
84
|
| `parallel(thunks)` / `pipeline(items, …stages)` | Raw `Promise.all` helpers |
|
|
82
85
|
| `nova.speculate(fn)` | Counterfactual branch (rollback / commit) |
|
|
@@ -144,6 +147,7 @@ Pair with DCE last if you use it: `omp install npm:pi-deferred-context-engine`.
|
|
|
144
147
|
- Guest JS is **unsandboxed**. Adapter path jails are not a boundary against `import("node:fs")`. The worker only contains hangs, exits, and memory — not intent.
|
|
145
148
|
- Guest error messages carry `(line:col)` on Node; Bun's engine does not expose guest-relative positions.
|
|
146
149
|
- `bash` / mutating tools flush speculative writes (transaction barrier); error rollback cannot undo that.
|
|
150
|
+
- The workspace index refreshes its file list every 10s or on any supernova mutation; a file created by an external process can take up to 10s to appear in `glob`/`snap` (`read` is never stale).
|
|
147
151
|
- Pre-1.0 package — APIs and TUI may still evolve between minor releases.
|
|
148
152
|
|
|
149
153
|
## License
|
package/catalog.js
CHANGED
|
@@ -33,6 +33,15 @@ export const NATIVE_TOOL_DEFINITIONS = [
|
|
|
33
33
|
path: { type: "string", description: "Optional workspace search root; explicitly targeting a hidden directory includes its hidden files, but Git metadata is always excluded" },
|
|
34
34
|
}, required: ["query"] },
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
name: "evidence", description: "Top-K source spans (with path and line provenance) that answer a concept question; read these instead of whole files.",
|
|
38
|
+
parameters: { type: "object", properties: {
|
|
39
|
+
query: { type: "string", description: "Concept, symbol, or question" },
|
|
40
|
+
path: { type: "string", description: "Optional search root" },
|
|
41
|
+
k: { type: "number", description: "Main spans to return (default 5)" },
|
|
42
|
+
maxChars: { type: "number", description: "Total text budget (default 6000)" },
|
|
43
|
+
}, required: ["query"] },
|
|
44
|
+
},
|
|
36
45
|
{
|
|
37
46
|
name: "surface", description: "Extract a structural outline from a workspace source file.",
|
|
38
47
|
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
@@ -142,12 +151,7 @@ export function searchCatalog(catalog, query, limit = 12) {
|
|
|
142
151
|
for (const row of catalog) {
|
|
143
152
|
const score = scoreRow(row, tokens);
|
|
144
153
|
if (score <= 0 && tokens.length > 0) continue;
|
|
145
|
-
scored.push({
|
|
146
|
-
name: row.name,
|
|
147
|
-
description: row.description.slice(0, 160),
|
|
148
|
-
score,
|
|
149
|
-
callable: true,
|
|
150
|
-
});
|
|
154
|
+
scored.push({ name: row.name, description: row.description.slice(0, 160), score });
|
|
151
155
|
}
|
|
152
156
|
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
153
157
|
return scored.slice(0, Math.max(1, limit)).map(({ score: _s, ...hit }) => hit);
|
|
@@ -155,9 +159,10 @@ export function searchCatalog(catalog, query, limit = 12) {
|
|
|
155
159
|
|
|
156
160
|
function fieldSummary(key, schema, required) {
|
|
157
161
|
const s = schema && isObject(schema) ? schema : {};
|
|
162
|
+
// Only signal-bearing keys: `required:false` and empty descriptions cost tokens and say nothing.
|
|
158
163
|
return {
|
|
159
164
|
type: s.type || (Array.isArray(s.anyOf) ? "union" : "unknown"),
|
|
160
|
-
required: required.has(key),
|
|
165
|
+
required: required.has(key) || undefined,
|
|
161
166
|
description: isString(s.description) ? s.description.slice(0, 120) : undefined,
|
|
162
167
|
};
|
|
163
168
|
}
|
|
@@ -233,7 +238,6 @@ export function describeTool(catalog, name) {
|
|
|
233
238
|
description: row.description,
|
|
234
239
|
parameters: schemaSummary(row.parameters),
|
|
235
240
|
sourcePath: row.sourcePath,
|
|
236
|
-
signature: `await nova.call(${JSON.stringify(row.name)}, args)`,
|
|
237
241
|
};
|
|
238
242
|
}
|
|
239
243
|
return row._described;
|
package/evidence.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { WorkspaceIndex } from "./repo-index.js";
|
|
3
|
+
import { tokenizeQuery, scorePathTopology } from "./snap.js";
|
|
4
|
+
|
|
5
|
+
// Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
|
|
6
|
+
// The codebase is the interaction history H; declared spans are the context units;
|
|
7
|
+
// identifiers are the entities. Every step below is deterministic — no model call —
|
|
8
|
+
// and every returned unit carries provenance (path, lines) back to the raw source.
|
|
9
|
+
//
|
|
10
|
+
// eq.3 G = (Vd ∪ Ve, Ede ∪ Edd) span/identifier nodes, co-occurrence + adjacency edges
|
|
11
|
+
// eq.4 w(d,e) = c(e,d) / Σ_e' c(e',d) entity–span weight
|
|
12
|
+
// eq.5 T(H) = file ∪ span ∪ line ∪ local granularities
|
|
13
|
+
// eq.6 ϕ(q) = {subject, keywords, type, temporal, boundary}
|
|
14
|
+
// eq.7 Route(q) ∈ {relational, local} → primary view weight ρ
|
|
15
|
+
// eq.8 η0(e|q) = sim(e, ê) lexical alignment (no encoder)
|
|
16
|
+
// eq.9 η1(e') = Σ_e η0(e) Σ_{z ∈ Z(e)∩Z(e')} sim(q, z)
|
|
17
|
+
// eq.10 π = (1−γ) r + γ Pᵀ π personalized PageRank over spans
|
|
18
|
+
// eq.11 file → span → line coarse-to-fine hierarchical view
|
|
19
|
+
// eq.12 Ŝv(d) = (Sv(d) − min) / (max − min) per-view min-max normalisation
|
|
20
|
+
// eq.13 Sfuse = ρ Ŝprimary + (1−ρ) Ŝsecondary
|
|
21
|
+
// eq.14 C(q) = Dedup(M ∪ Ng(M) ∪ Nh(M)) closure: bridges + neighbours
|
|
22
|
+
// eq.15 R(q) = Rank_ϕ(Filter(C, ϕ)) deterministic calibration
|
|
23
|
+
|
|
24
|
+
export const EVIDENCE_DEFAULTS = {
|
|
25
|
+
k: 5, // paper: Top-5 within 0.65 F1 of Top-10 at half the candidates
|
|
26
|
+
rho: 0.7, // primary-view weight
|
|
27
|
+
gamma: 0.85, // PPR damping
|
|
28
|
+
pprIterations: 20,
|
|
29
|
+
maxSpanLines: 60,
|
|
30
|
+
maxChars: 6000, // total text budget of R(q)
|
|
31
|
+
maxCandidateFiles: 24,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const IDENT = /[A-Za-z_$][\w$]*/g;
|
|
35
|
+
// Verb forms only: "call sites" is a concept, "who calls X" is a usage question.
|
|
36
|
+
const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
|
|
37
|
+
const HUB_FRACTION = 0.25;
|
|
38
|
+
const HUB_MIN = 8;
|
|
39
|
+
const TYPE_CUES = [
|
|
40
|
+
["test", /\b(test|tests|spec)\b/],
|
|
41
|
+
["doc", /\b(doc|docs|readme|documentation)\b/],
|
|
42
|
+
["config", /\b(config|configuration|settings|option|options|default|defaults)\b/],
|
|
43
|
+
["type", /\b(type|types|interface|schema|struct)\b/],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
|
|
47
|
+
export function stem(token) {
|
|
48
|
+
if (token.length < 5) return token;
|
|
49
|
+
return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitIdentifier(name) {
|
|
53
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** eq.6 — query profile from the query text and the call's boundary. */
|
|
57
|
+
export function profileQuery(query, root) {
|
|
58
|
+
const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
|
|
59
|
+
const words = query.match(IDENT) || [];
|
|
60
|
+
// Subjects are identifier-shaped words (camelCase / snake_case): they anchor the graph view.
|
|
61
|
+
const subjects = words.filter((w) => /[a-z][A-Z]|_/.test(w));
|
|
62
|
+
const usage = words.some((w) => RELATION_WORDS.has(w.toLowerCase()));
|
|
63
|
+
const relational = usage || subjects.length > 0;
|
|
64
|
+
let answerType = usage ? "usage" : "definition";
|
|
65
|
+
for (const [type, re] of TYPE_CUES) if (re.test(query.toLowerCase())) answerType = type;
|
|
66
|
+
return {
|
|
67
|
+
subjects: [...new Set(subjects)],
|
|
68
|
+
keywords: tokens,
|
|
69
|
+
stems: [...new Set(tokens.map(stem))],
|
|
70
|
+
answerType,
|
|
71
|
+
flags: { wantsTest, wantsType, wantsDoc },
|
|
72
|
+
boundary: root,
|
|
73
|
+
route: relational ? "relational" : "local", // eq.7
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- substrate: spans (context units) from the structural surface ----
|
|
78
|
+
|
|
79
|
+
function spansOf(entry, filePath, maxSpanLines) {
|
|
80
|
+
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
81
|
+
const lines = WorkspaceIndex.linesOf(entry);
|
|
82
|
+
const lower = lines.lower;
|
|
83
|
+
if (items.length === 0) {
|
|
84
|
+
return [{ id: filePath + ":1", path: filePath, start: 1, end: Math.min(lineCount, maxSpanLines), name: path.basename(filePath), kind: "file", entry, lower, lines }];
|
|
85
|
+
}
|
|
86
|
+
const spans = [];
|
|
87
|
+
for (let i = 0; i < items.length; i++) {
|
|
88
|
+
const start = items[i].line;
|
|
89
|
+
const nextStart = i + 1 < items.length ? items[i + 1].line : lineCount + 1;
|
|
90
|
+
let end = Math.min(nextStart - 1, start + maxSpanLines - 1, lineCount);
|
|
91
|
+
while (end > start && lower[end - 1] === "") end--;
|
|
92
|
+
spans.push({ id: filePath + ":" + start, path: filePath, start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true, entry, lower, lines, index: i });
|
|
93
|
+
}
|
|
94
|
+
return spans;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function spanLines(span) {
|
|
98
|
+
return span.lower.slice(span.start - 1, span.end);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- eq.3 / eq.4: entity–context graph over candidate spans ----
|
|
102
|
+
|
|
103
|
+
function buildGraph(spans) {
|
|
104
|
+
const entityNames = new Set(spans.map((s) => s.name).filter((n) => n && n.length > 2));
|
|
105
|
+
const spanEntities = new Map(); // span.id → Map(entity → w(d,e))
|
|
106
|
+
const entitySpans = new Map(); // entity → Set(span.id)
|
|
107
|
+
for (const span of spans) {
|
|
108
|
+
const counts = new Map();
|
|
109
|
+
let total = 0;
|
|
110
|
+
const { idents } = span.lines;
|
|
111
|
+
for (let li = span.start - 1; li < span.end; li++) {
|
|
112
|
+
for (const word of idents[li]) {
|
|
113
|
+
if (!entityNames.has(word)) continue;
|
|
114
|
+
counts.set(word, (counts.get(word) || 0) + 1);
|
|
115
|
+
total += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const weights = new Map();
|
|
119
|
+
for (const [e, c] of counts) {
|
|
120
|
+
weights.set(e, c / total); // eq.4
|
|
121
|
+
if (!entitySpans.has(e)) entitySpans.set(e, new Set());
|
|
122
|
+
entitySpans.get(e).add(span.id);
|
|
123
|
+
}
|
|
124
|
+
spanEntities.set(span.id, weights);
|
|
125
|
+
}
|
|
126
|
+
// Entities present in a large share of spans (isString, path, …) carry no query signal; keep them out of propagation.
|
|
127
|
+
const hubLimit = Math.max(HUB_MIN, Math.floor(spans.length * HUB_FRACTION));
|
|
128
|
+
const hubs = new Set([...entitySpans].filter(([, ids]) => ids.size > hubLimit).map(([e]) => e));
|
|
129
|
+
return { entityNames, spanEntities, entitySpans, hubs, byId: new Map(spans.map((s) => [s.id, s])) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- eq.8 / eq.9: entity activation and one propagation step ----
|
|
133
|
+
|
|
134
|
+
function lexicalSim(a, b) {
|
|
135
|
+
const ta = new Set(splitIdentifier(a));
|
|
136
|
+
const tb = new Set(splitIdentifier(b));
|
|
137
|
+
if (ta.size === 0 || tb.size === 0) return 0;
|
|
138
|
+
let inter = 0;
|
|
139
|
+
for (const t of ta) if (tb.has(t)) inter++;
|
|
140
|
+
return inter / (ta.size + tb.size - inter);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function activateEntities(profile, graph) {
|
|
144
|
+
const eta = new Map();
|
|
145
|
+
const anchors = profile.subjects.length ? profile.subjects : profile.keywords;
|
|
146
|
+
for (const anchor of anchors) {
|
|
147
|
+
let best = null;
|
|
148
|
+
let bestSim = 0;
|
|
149
|
+
for (const e of graph.entityNames) {
|
|
150
|
+
const sim = e.toLowerCase() === anchor.toLowerCase() ? 1 : lexicalSim(e, anchor);
|
|
151
|
+
if (sim > bestSim) {
|
|
152
|
+
bestSim = sim;
|
|
153
|
+
best = e;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (best && bestSim >= 0.5) eta.set(best, Math.max(eta.get(best) || 0, bestSim)); // eq.8
|
|
157
|
+
}
|
|
158
|
+
return eta;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function querySim(profile, lowerLine) {
|
|
162
|
+
let hits = 0;
|
|
163
|
+
for (const t of profile.stems) if (lowerLine.includes(t)) hits++;
|
|
164
|
+
return profile.stems.length ? hits / profile.stems.length : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Co-occurring entities on one query-relevant line receive act·sim(q,z)·idf (eq.9, IDF-damped). */
|
|
168
|
+
function activateCooccurring(e, line, weight, spanId, graph, eta1) {
|
|
169
|
+
for (const [other] of graph.spanEntities.get(spanId)) {
|
|
170
|
+
if (other === e || graph.hubs.has(other) || !line.includes(other.toLowerCase())) continue;
|
|
171
|
+
const idf = 1 / Math.log2(1 + graph.entitySpans.get(other).size);
|
|
172
|
+
eta1.set(other, (eta1.get(other) || 0) + weight * idf);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function propagateFrom(e, act, graph, profile, eta1) {
|
|
177
|
+
const eLower = e.toLowerCase();
|
|
178
|
+
for (const spanId of graph.entitySpans.get(e) || []) {
|
|
179
|
+
for (const line of spanLines(graph.byId.get(spanId))) {
|
|
180
|
+
if (!line.includes(eLower)) continue;
|
|
181
|
+
const sim = querySim(profile, line);
|
|
182
|
+
if (sim > 0) activateCooccurring(e, line, act * sim, spanId, graph, eta1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function propagate(eta0, spans, graph, profile) {
|
|
188
|
+
const eta1 = new Map(eta0);
|
|
189
|
+
for (const [e, act] of eta0) propagateFrom(e, act, graph, profile, eta1);
|
|
190
|
+
return eta1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- eq.10: personalized PageRank over spans ----
|
|
194
|
+
|
|
195
|
+
function resetDistribution(spans, graph, eta, prior) {
|
|
196
|
+
const n = spans.length;
|
|
197
|
+
const reset = new Float64Array(n);
|
|
198
|
+
let sum = 0;
|
|
199
|
+
for (let i = 0; i < n; i++) {
|
|
200
|
+
let r = prior[i];
|
|
201
|
+
for (const [e, w] of graph.spanEntities.get(spans[i].id)) if (!graph.hubs.has(e)) r += (eta.get(e) || 0) * w;
|
|
202
|
+
reset[i] = r;
|
|
203
|
+
sum += r;
|
|
204
|
+
}
|
|
205
|
+
if (sum > 0) for (let i = 0; i < n; i++) reset[i] /= sum;
|
|
206
|
+
return { reset, sum };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Transition structure d → d' = Σ_e w(d,e)·w(d',e) over shared non-hub entities plus 0.5 per in-file
|
|
211
|
+
* neighbour (Edd). Kept factored through the entity layer so an iteration costs O(nnz), never O(n²).
|
|
212
|
+
*/
|
|
213
|
+
function transitionStructure(spans, graph) {
|
|
214
|
+
const n = spans.length;
|
|
215
|
+
const entities = [...graph.entitySpans].filter(([e, ids]) => ids.size >= 2 && !graph.hubs.has(e)).map(([e]) => e);
|
|
216
|
+
const eIndex = new Map(entities.map((e, i) => [e, i]));
|
|
217
|
+
const spanTerms = spans.map((s) => {
|
|
218
|
+
const terms = [];
|
|
219
|
+
for (const [e, w] of graph.spanEntities.get(s.id)) if (eIndex.has(e)) terms.push([eIndex.get(e), w]);
|
|
220
|
+
return terms;
|
|
221
|
+
});
|
|
222
|
+
const entityMass = new Float64Array(entities.length);
|
|
223
|
+
for (let i = 0; i < n; i++) for (const [ei, w] of spanTerms[i]) entityMass[ei] += w;
|
|
224
|
+
const neighbours = spans.map((s, i) => [i - 1, i + 1].filter((j) => j >= 0 && j < n && spans[j].path === s.path));
|
|
225
|
+
const outWeight = new Float64Array(n);
|
|
226
|
+
for (let i = 0; i < n; i++) {
|
|
227
|
+
let out = 0.5 * neighbours[i].length;
|
|
228
|
+
for (const [ei, w] of spanTerms[i]) out += w * (entityMass[ei] - w);
|
|
229
|
+
outWeight[i] = out;
|
|
230
|
+
}
|
|
231
|
+
return { spanTerms, entityCount: entities.length, neighbours, outWeight };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pushStep(pi, next, acc, structure, gamma) {
|
|
235
|
+
const { spanTerms, neighbours, outWeight } = structure;
|
|
236
|
+
let dangling = 0;
|
|
237
|
+
for (let i = 0; i < pi.length; i++) {
|
|
238
|
+
if (outWeight[i] === 0) {
|
|
239
|
+
dangling += pi[i];
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const flow = (gamma * pi[i]) / outWeight[i];
|
|
243
|
+
for (const [ei, w] of spanTerms[i]) {
|
|
244
|
+
acc[ei] += flow * w;
|
|
245
|
+
next[i] -= flow * w * w; // remove the d → d self term
|
|
246
|
+
}
|
|
247
|
+
for (const j of neighbours[i]) next[j] += flow * 0.5;
|
|
248
|
+
}
|
|
249
|
+
return dangling;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pageRank(spans, graph, eta, prior, { gamma, pprIterations }) {
|
|
253
|
+
const n = spans.length;
|
|
254
|
+
const { reset, sum } = resetDistribution(spans, graph, eta, prior);
|
|
255
|
+
if (sum === 0) return reset;
|
|
256
|
+
const structure = transitionStructure(spans, graph);
|
|
257
|
+
const acc = new Float64Array(structure.entityCount);
|
|
258
|
+
let pi = Float64Array.from(reset);
|
|
259
|
+
for (let iter = 0; iter < pprIterations; iter++) {
|
|
260
|
+
const next = new Float64Array(n);
|
|
261
|
+
acc.fill(0);
|
|
262
|
+
const dangling = pushStep(pi, next, acc, structure, gamma);
|
|
263
|
+
for (let i = 0; i < n; i++) {
|
|
264
|
+
for (const [ei, w] of structure.spanTerms[i]) next[i] += acc[ei] * w;
|
|
265
|
+
next[i] += (1 - gamma) * reset[i] + gamma * dangling * reset[i]; // dangling mass follows the reset
|
|
266
|
+
}
|
|
267
|
+
pi = next;
|
|
268
|
+
}
|
|
269
|
+
return pi;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- eq.11: hierarchical view (file → span → line) ----
|
|
273
|
+
|
|
274
|
+
function nameDefinitionScore(span, profile, usage) {
|
|
275
|
+
if (usage) return 0; // eq.15 Rank_ϕ: a usage question is answered by callers, not the definer
|
|
276
|
+
const nameTokens = splitIdentifier(span.name || "");
|
|
277
|
+
let def = 0;
|
|
278
|
+
for (const t of profile.stems) if (nameTokens.some((n) => n.startsWith(t))) def += 40;
|
|
279
|
+
return def;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function mentionScore(span, profile, usage, skipDeclaration) {
|
|
283
|
+
const perHit = usage ? 15 : 5;
|
|
284
|
+
let mentions = 0;
|
|
285
|
+
let bestLine = span.start;
|
|
286
|
+
let bestHits = 0;
|
|
287
|
+
const lines = spanLines(span);
|
|
288
|
+
for (let i = skipDeclaration ? 1 : 0; i < lines.length; i++) {
|
|
289
|
+
let hits = 0;
|
|
290
|
+
for (const t of profile.stems) if (lines[i].includes(t)) hits++;
|
|
291
|
+
if (hits > bestHits) {
|
|
292
|
+
bestHits = hits;
|
|
293
|
+
bestLine = span.start + i;
|
|
294
|
+
}
|
|
295
|
+
mentions += hits * hits * perHit; // several query stems on one line is strong evidence
|
|
296
|
+
}
|
|
297
|
+
return { mentions, bestLine };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function hierarchicalScores(spans, fileScores, profile) {
|
|
301
|
+
const usage = profile.answerType === "usage";
|
|
302
|
+
return spans.map((span) => {
|
|
303
|
+
const definesSubject = profile.subjects.includes(span.name);
|
|
304
|
+
const def = nameDefinitionScore(span, profile, usage);
|
|
305
|
+
const { mentions, bestLine } = mentionScore(span, profile, usage, usage && definesSubject);
|
|
306
|
+
span.bestLine = bestLine;
|
|
307
|
+
span.support = def + mentions; // span-level lexical evidence; file-level bonuses do not count
|
|
308
|
+
const fileScore = Math.max(0, fileScores.get(span.path) || 0);
|
|
309
|
+
return fileScore / 2 + def + Math.min(mentions, usage ? 200 : 120) + (span.isExport ? 10 : 0);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- eq.12 / eq.13 ----
|
|
314
|
+
|
|
315
|
+
function normalize(scores) {
|
|
316
|
+
let min = Infinity;
|
|
317
|
+
let max = -Infinity;
|
|
318
|
+
for (const s of scores) {
|
|
319
|
+
if (s < min) min = s;
|
|
320
|
+
if (s > max) max = s;
|
|
321
|
+
}
|
|
322
|
+
if (!(max > min)) return scores.map(() => 1);
|
|
323
|
+
return scores.map((s) => (s - min) / (max - min));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- candidate files (boundary + topology + entity hits) ----
|
|
327
|
+
|
|
328
|
+
function candidateFiles(files, profile, index, limit) {
|
|
329
|
+
const scored = [];
|
|
330
|
+
for (const f of files) {
|
|
331
|
+
const s = scorePathTopology(f, profile.keywords, profile.flags);
|
|
332
|
+
if (s > 0) scored.push({ f, s });
|
|
333
|
+
}
|
|
334
|
+
scored.sort((a, b) => b.s - a.s);
|
|
335
|
+
const chosen = new Set(scored.slice(0, limit).map(({ f }) => f));
|
|
336
|
+
const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
337
|
+
const hits = anchors.length ? index.filesContaining(files, anchors, true) : [];
|
|
338
|
+
for (const f of hits) {
|
|
339
|
+
if (chosen.size >= limit) break;
|
|
340
|
+
if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
|
|
341
|
+
}
|
|
342
|
+
return { files: [...chosen], fileScores: new Map(scored.map(({ f, s }) => [f, s])) };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- eq.14 / eq.15 ----
|
|
346
|
+
|
|
347
|
+
function bridgesFor(i, spans, graph, fused, definers, chosen) {
|
|
348
|
+
const byId = graph.byId;
|
|
349
|
+
const bridges = [];
|
|
350
|
+
for (const [e, w] of graph.spanEntities.get(spans[i].id)) {
|
|
351
|
+
const defId = definers.get(e);
|
|
352
|
+
if (!defId || graph.hubs.has(e) || chosen.has(defId) || defId === spans[i].id || w < 0.15) continue;
|
|
353
|
+
const definer = byId.get(defId);
|
|
354
|
+
if (definer.end - definer.start < 2) continue; // one-line helpers add no understanding
|
|
355
|
+
bridges.push({ id: defId, w: w * fused[definer.index0] });
|
|
356
|
+
}
|
|
357
|
+
return bridges.sort((a, b) => b.w - a.w).slice(0, 2);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function closure(main, spans, graph, fused, k) {
|
|
361
|
+
spans.forEach((s, i) => (s.index0 = i));
|
|
362
|
+
const chosen = new Map(main.map((i) => [spans[i].id, { i, why: "main" }]));
|
|
363
|
+
const definers = new Map();
|
|
364
|
+
for (const s of spans) if (s.name) definers.set(s.name, s.id);
|
|
365
|
+
const neighboursOf = (i) => [i - 1, i + 1].filter((j) => j >= 0 && j < spans.length && spans[j].path === spans[i].path);
|
|
366
|
+
for (const i of main) {
|
|
367
|
+
// Ng: spans that define identifiers this span uses (relational bridges).
|
|
368
|
+
for (const b of bridgesFor(i, spans, graph, fused, definers, chosen)) chosen.set(b.id, { i: graph.byId.get(b.id).index0, why: "bridge" });
|
|
369
|
+
// Nh: in-file neighbours that still carry query signal.
|
|
370
|
+
for (const j of neighboursOf(i)) {
|
|
371
|
+
if (fused[j] > 0.2 && !chosen.has(spans[j].id)) chosen.set(spans[j].id, { i: j, why: "neighbor" });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const supports = [...chosen.values()].filter((c) => c.why !== "main").sort((a, b) => fused[b.i] - fused[a.i]).slice(0, k);
|
|
375
|
+
return [...main.map((i) => ({ i, why: "main" })), ...supports];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function render(spans, picks, fused, opts, root) {
|
|
379
|
+
const out = [];
|
|
380
|
+
let budget = opts.maxChars;
|
|
381
|
+
for (const { i, why } of picks) {
|
|
382
|
+
const span = spans[i];
|
|
383
|
+
const lines = span.lines.raw.slice(span.start - 1, span.end);
|
|
384
|
+
let text = lines.join("\n");
|
|
385
|
+
if (text.length > budget) text = text.slice(0, Math.max(0, budget - 1)) + "…";
|
|
386
|
+
budget -= text.length;
|
|
387
|
+
out.push({
|
|
388
|
+
path: path.relative(root, span.path) || span.path,
|
|
389
|
+
lines: [span.start, span.start + lines.length - 1],
|
|
390
|
+
name: span.name,
|
|
391
|
+
kind: span.kind,
|
|
392
|
+
why,
|
|
393
|
+
text,
|
|
394
|
+
});
|
|
395
|
+
if (budget <= 0) break;
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
|
|
402
|
+
* @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
|
|
403
|
+
*/
|
|
404
|
+
export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, options = {} }) {
|
|
405
|
+
const opts = { ...EVIDENCE_DEFAULTS, ...options };
|
|
406
|
+
const profile = profileQuery(query, root);
|
|
407
|
+
if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
|
|
408
|
+
const files = await index.files(searchDir || root);
|
|
409
|
+
if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
|
|
410
|
+
|
|
411
|
+
const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles);
|
|
412
|
+
const spans = [];
|
|
413
|
+
for (const f of chosenFiles) {
|
|
414
|
+
const pending = overlayText(f);
|
|
415
|
+
const entry = pending === undefined ? index.entry(f) : WorkspaceIndex.fromText(f, pending);
|
|
416
|
+
if (!entry) continue;
|
|
417
|
+
spans.push(...spansOf(entry, f, opts.maxSpanLines));
|
|
418
|
+
}
|
|
419
|
+
if (spans.length === 0) return { route: profile.route, spans: [] };
|
|
420
|
+
|
|
421
|
+
const graph = buildGraph(spans);
|
|
422
|
+
const hier = hierarchicalScores(spans, fileScores, profile);
|
|
423
|
+
const hierNorm = normalize(hier);
|
|
424
|
+
const eta = propagate(activateEntities(profile, graph), spans, graph, profile);
|
|
425
|
+
const pi = pageRank(spans, graph, eta, hierNorm.map((s) => s * 0.5), opts);
|
|
426
|
+
const graphNorm = normalize([...pi]);
|
|
427
|
+
|
|
428
|
+
const [primary, secondary] = profile.route === "relational" ? [graphNorm, hierNorm] : [hierNorm, graphNorm];
|
|
429
|
+
const fused = primary.map((p, i) => opts.rho * p + (1 - opts.rho) * secondary[i]); // eq.13
|
|
430
|
+
|
|
431
|
+
// eq.15 Filter: boundary/type hard constraints and lexical support; Rank_ϕ: answer-type compatibility.
|
|
432
|
+
const usage = profile.answerType === "usage";
|
|
433
|
+
const admissible = spans.map((s, i) => i).filter((i) => {
|
|
434
|
+
const p = spans[i].path;
|
|
435
|
+
const isTestSpan = /(^|[\\/])(test|tests)[\\/]|\.(test|spec)\./.test(p);
|
|
436
|
+
const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
|
|
437
|
+
return spans[i].support > 0 && (profile.flags.wantsTest || !isTestSpan) && (profile.flags.wantsDoc || !isDoc);
|
|
438
|
+
});
|
|
439
|
+
for (const i of admissible) {
|
|
440
|
+
if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
|
|
441
|
+
}
|
|
442
|
+
const ranked = admissible.sort((a, b) => fused[b] - fused[a] || spans[a].path.localeCompare(spans[b].path) || spans[a].start - spans[b].start);
|
|
443
|
+
const main = ranked.slice(0, opts.k);
|
|
444
|
+
const picks = closure(main, spans, graph, fused, opts.k);
|
|
445
|
+
return { route: profile.route, spans: render(spans, picks, fused, opts, root) };
|
|
446
|
+
}
|
package/guest-worker.js
CHANGED
|
@@ -11,7 +11,7 @@ const compiledCache = new Map();
|
|
|
11
11
|
const COMPILED_CACHE_MAX = 256;
|
|
12
12
|
const PARAMS = [
|
|
13
13
|
"nova", "tools", "console", "parallel", "pipeline",
|
|
14
|
-
"read", "write", "edit", "patch", "surface", "snap", "bash", "exec", "speculate",
|
|
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;
|
|
@@ -229,6 +229,7 @@ function buildGuestApi(available) {
|
|
|
229
229
|
}
|
|
230
230
|
},
|
|
231
231
|
surface: async (filePath) => unwrapJsonValue(await rpc("surface", [filePath])),
|
|
232
|
+
evidence: async (query, opts) => unwrapJsonValue(await rpc("call", ["evidence", { query, ...opts }])),
|
|
232
233
|
snap: async (query, targetPath) => unwrapJsonValue(await rpc("snap", [query, targetPath])),
|
|
233
234
|
has: (name) => availableSet.has(name),
|
|
234
235
|
};
|
|
@@ -269,7 +270,7 @@ function buildGuestApi(available) {
|
|
|
269
270
|
return bash([command, ...args].map(quoteShellArg).join(" "), opts);
|
|
270
271
|
};
|
|
271
272
|
|
|
272
|
-
return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, bash, exec, speculate: nova.speculate };
|
|
273
|
+
return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
function makeConsole(runId, limits) {
|
|
@@ -312,7 +313,7 @@ async function handleRun(msg) {
|
|
|
312
313
|
try {
|
|
313
314
|
const value = await compiled(
|
|
314
315
|
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,
|
|
316
|
+
api.read, api.write, api.edit, api.patch, api.surface, api.snap, api.evidence, api.bash, api.exec, api.speculate,
|
|
316
317
|
);
|
|
317
318
|
if (runId !== activeRunId) return;
|
|
318
319
|
let plain;
|