pi-fovea 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +1 -1
- package/skills/pi-fovea/SKILL.md +2 -2
- package/src/core/astgrep.ts +46 -4
- package/src/core/build.ts +1 -1
- package/src/core/extract.ts +112 -2
- package/src/core/ops.ts +138 -8
- package/src/core/render.ts +103 -13
- package/src/core/types.ts +3 -1
- package/src/index.ts +3 -3
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ _See the whole repo on every prompt, sharp where you work and cheap everywhere e
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
pi-fovea hands the model a map of your repo on every prompt. The repo compiles once into a code graph across languages, where symbols, files, and route anchors join into one network. Each question becomes an interest vector that diffuses over the graph as heat. The renderer converts the field into a token-capped view: full signatures near your task, one-
|
|
20
|
+
pi-fovea hands the model a map of your repo on every prompt. The repo compiles once into a code graph across languages, where symbols, files, and route anchors join into one network. Each question becomes an interest vector that diffuses over the graph as heat. The renderer converts the field into a token-capped view: exact source locations and full signatures near your task, typed one-hop relationships next, and a skeleton of the rest.
|
|
21
21
|
|
|
22
22
|
After each assistant turn the map re-syncs incrementally. Detection reads content hashes instead of tool events, so edits made by pi's edit/write tools, a fabric_exec inner `pi.edit`, a bash heredoc, a subagent, or an editor save outside the session all land identically. A clean turn stays silent. A turn that moves route anchors or warms files you have not looked at says so.
|
|
23
23
|
|
|
@@ -26,11 +26,13 @@ After each assistant turn the map re-syncs incrementally. Detection reads conten
|
|
|
26
26
|
| Command | Ask | Answer |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `fovea_sketch` | where is everything? | the repo as a silhouette, with feature anchors and inferred regions ranked by mass |
|
|
29
|
-
| `fovea_focus` | what is this? | centered on a symbol, route path, or env key: hot
|
|
29
|
+
| `fovea_focus` | what is this? | centered on a symbol, route path, or env key: exact hot signatures, typed direct relationships, then warm one-liners |
|
|
30
30
|
| `fovea_dwell` | what else? | diffuses the field one step further and returns the delta |
|
|
31
31
|
| `fovea_impact` | what does this touch? | warms everything a file, symbol, or PR base reaches across languages |
|
|
32
32
|
| `grep` *(default override)* | where does this concept lead? | the same graph-backed focus through grep's familiar `pattern/path/glob/...` signature |
|
|
33
33
|
|
|
34
|
+
Focus normalizes camelCase and common inflections, so an approximate name such as `switchServer` can resolve `switchingServers`. If a query is still uncertain, Fovea returns nearby symbols with locations instead of a dead miss. Direct graph edges are labeled (caller, callee, route, shared literal, co-change), while unrelated same-file siblings remain collapsed.
|
|
35
|
+
|
|
34
36
|
The **Replace grep** toggle makes Fovea own Pi's `grep` tool slot. It is on by default. Familiar calls such as `grep({ pattern: "CreateUser", path: "src" })` navigate the code graph first; use `bash` with `rg` only when you need exact matching lines. Disable the toggle to restore the previous grep implementation. Changing the toggle reloads extensions so pi-fabric captures the same override and `pi.grep(...)` follows it inside `fabric_exec`.
|
|
35
37
|
|
|
36
38
|
### pi-fabric
|
package/package.json
CHANGED
package/skills/pi-fovea/SKILL.md
CHANGED
|
@@ -10,8 +10,8 @@ pi-fovea maintains a cross-language code graph of the working repository — rou
|
|
|
10
10
|
## The loop
|
|
11
11
|
|
|
12
12
|
1. **`fovea_sketch`** — silhouettes only. Route/anchor inventory plus directory blobs ranked by heat. Start here in an unfamiliar repo. ~256–1024 tokens.
|
|
13
|
-
2. **`fovea_focus` `<query>`** — point at a symbol name, route path (`/api/users/{id}`), env key, or file path. Hot nodes
|
|
14
|
-
3. **`fovea_dwell`** — optional second look. If a focus footer
|
|
13
|
+
2. **`fovea_focus` `<query>`** — point at a symbol name (close spellings work), route path (`/api/users/{id}`), env key, or file path. Hot nodes carry exact source locations and signatures; direct callers/callees and other typed edges are labeled; the periphery stays collapsed. A true miss suggests nearby symbols. Already-shown nodes are suppressed, so repeated focus calls stay cheap.
|
|
14
|
+
3. **`fovea_dwell`** — optional second look. If a focus footer reports a remaining low-acuity periphery, dwell (diffusion time ×2) returns newly warmed neighbors.
|
|
15
15
|
4. **`fovea_impact`** — blast radius. Seed with explicit repo-relative `files`, symbol names for what-if analysis, or uncommitted changes (`base` works PR-style against a ref). Output is the predicted co-change cascade ordered by warmth.
|
|
16
16
|
|
|
17
17
|
All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
|
package/src/core/astgrep.ts
CHANGED
|
@@ -83,10 +83,52 @@ export const groupByLang = (files: string[]): Map<string, string[]> => {
|
|
|
83
83
|
return m;
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
// `ast-grep outline`
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
// `ast-grep outline` is the uniform symbol source across languages.
|
|
87
|
+
// Expanded JSON is primary; the legacy text view remains as a compatibility fallback.
|
|
88
|
+
export interface OutlineRange {
|
|
89
|
+
start: { line: number; column: number };
|
|
90
|
+
end?: { line: number; column: number };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface OutlineSymbol {
|
|
94
|
+
role: "item" | "member";
|
|
95
|
+
symbolType: string;
|
|
96
|
+
name: string;
|
|
97
|
+
range: OutlineRange;
|
|
98
|
+
signature: string;
|
|
99
|
+
astKind?: string;
|
|
100
|
+
members?: OutlineSymbol[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface OutlineFile {
|
|
104
|
+
path: string;
|
|
105
|
+
language: string;
|
|
106
|
+
items: OutlineSymbol[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Expanded JSON preserves each member's own range and signature. Return
|
|
110
|
+
// undefined when the installed ast-grep predates this interface so callers can
|
|
111
|
+
// fall back without presenting parent locations as exact member locations.
|
|
112
|
+
export const outlineStructured = (files: string[], lang: string, cwd: string): OutlineFile[] | undefined => {
|
|
113
|
+
const out: OutlineFile[] = [];
|
|
114
|
+
for (let i = 0; i < files.length; i += CHUNK) {
|
|
115
|
+
const stdout = run(
|
|
116
|
+
["outline", "--json=compact", "--view=expanded", ...files.slice(i, i + CHUNK)],
|
|
117
|
+
cwd,
|
|
118
|
+
);
|
|
119
|
+
if (!stdout.trim()) return undefined;
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(stdout) as OutlineFile[];
|
|
122
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
123
|
+
for (const file of parsed) out.push(file);
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
void lang;
|
|
129
|
+
return out;
|
|
130
|
+
};
|
|
131
|
+
|
|
90
132
|
export const outline = (files: string[], lang: string, cwd: string): string => {
|
|
91
133
|
let out = "";
|
|
92
134
|
for (let i = 0; i < files.length; i += CHUNK) {
|
package/src/core/build.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface FileFacts {
|
|
|
30
30
|
sigs?: FileSigs;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const CACHE_VERSION =
|
|
33
|
+
const CACHE_VERSION = 7; // bump when extractor semantics change
|
|
34
34
|
const IGNORE_DIRS = new Set([".git", "node_modules", "dist", "vendor", ".venv", "venv", "target", "coverage", ".next", "build", "__pycache__", ".pi", ".pi-fovea", "deps", "_build", ".tox", "Pods"]);
|
|
35
35
|
const MAX_FILES = 24000;
|
|
36
36
|
// Generated dependency manifests are enormous and carry no first-class routes.
|
package/src/core/extract.ts
CHANGED
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
groupByLang,
|
|
10
10
|
isConfigFile,
|
|
11
11
|
outline,
|
|
12
|
+
outlineStructured,
|
|
12
13
|
patternRunAll,
|
|
14
|
+
type OutlineFile,
|
|
15
|
+
type OutlineSymbol,
|
|
13
16
|
} from "./astgrep.js";
|
|
14
17
|
import type {
|
|
15
18
|
CallSite,
|
|
@@ -102,6 +105,104 @@ export const deriveName = (sig: string, lang: string, parentHint?: string): Name
|
|
|
102
105
|
return { name: first.replace(/^[*&]+/, "") || "?", kind: "decl" };
|
|
103
106
|
};
|
|
104
107
|
|
|
108
|
+
const OUTLINE_KINDS: Record<string, NodeKind> = {
|
|
109
|
+
class: "class",
|
|
110
|
+
struct: "class",
|
|
111
|
+
object: "class",
|
|
112
|
+
interface: "interface",
|
|
113
|
+
trait: "interface",
|
|
114
|
+
protocol: "interface",
|
|
115
|
+
enum: "type",
|
|
116
|
+
type: "type",
|
|
117
|
+
alias: "type",
|
|
118
|
+
function: "function",
|
|
119
|
+
method: "method",
|
|
120
|
+
field: "field",
|
|
121
|
+
property: "field",
|
|
122
|
+
constant: "decl",
|
|
123
|
+
variable: "decl",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const outlineKind = (symbol: OutlineSymbol, lang: string): NodeKind => {
|
|
127
|
+
if (symbol.symbolType === "constructor") return "method";
|
|
128
|
+
const mapped = OUTLINE_KINDS[symbol.symbolType];
|
|
129
|
+
if (symbol.role === "member" && mapped) return mapped;
|
|
130
|
+
const derived = deriveName(symbol.signature, lang).kind;
|
|
131
|
+
if (derived !== "decl") return derived;
|
|
132
|
+
return mapped ?? derived;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const identifierRe = (name: string): RegExp =>
|
|
136
|
+
new RegExp(`(^|[^A-Za-z0-9_$])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^A-Za-z0-9_$]|$)`);
|
|
137
|
+
|
|
138
|
+
const topLocation = (
|
|
139
|
+
file: string,
|
|
140
|
+
item: OutlineSymbol,
|
|
141
|
+
cwd: string,
|
|
142
|
+
sourceCache: Map<string, string[]>,
|
|
143
|
+
): { line: number; sig: string } => {
|
|
144
|
+
let line = item.range.start.line + 1;
|
|
145
|
+
let sig = cleanSig(item.signature || item.name);
|
|
146
|
+
if (item.name && (!identifierRe(item.name).test(sig) || /^@/.test(sig))) {
|
|
147
|
+
let lines = sourceCache.get(file);
|
|
148
|
+
if (!lines) {
|
|
149
|
+
try {
|
|
150
|
+
lines = readFileSync(join(cwd, file), "utf8").split("\n");
|
|
151
|
+
} catch {
|
|
152
|
+
lines = [];
|
|
153
|
+
}
|
|
154
|
+
sourceCache.set(file, lines);
|
|
155
|
+
}
|
|
156
|
+
const end = Math.min(lines.length - 1, item.range.end?.line ?? item.range.start.line + 12);
|
|
157
|
+
for (let i = item.range.start.line; i <= end; i++) {
|
|
158
|
+
const candidate = lines[i];
|
|
159
|
+
if (candidate && identifierRe(item.name).test(candidate)) {
|
|
160
|
+
line = i + 1;
|
|
161
|
+
sig = cleanSig(candidate);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { line, sig };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const parseStructuredOutline = (files: OutlineFile[], cwd: string): SymbolRec[] => {
|
|
170
|
+
const out: SymbolRec[] = [];
|
|
171
|
+
const sourceCache = new Map<string, string[]>();
|
|
172
|
+
for (const record of files) {
|
|
173
|
+
const file = record.path.replace(/^\.\//, "");
|
|
174
|
+
const concreteParents = new Set(
|
|
175
|
+
record.items.filter((item) => item.symbolType !== "object").map((item) => item.name),
|
|
176
|
+
);
|
|
177
|
+
for (const item of record.items) {
|
|
178
|
+
const kind = outlineKind(item, record.language);
|
|
179
|
+
let name = item.name;
|
|
180
|
+
if (kind === "method") {
|
|
181
|
+
const derived = deriveName(item.signature, record.language);
|
|
182
|
+
if (derived.kind === "method" && derived.name.includes(".")) name = derived.name;
|
|
183
|
+
}
|
|
184
|
+
// Rust impl/object outlines repeat the concrete type. Keep its members,
|
|
185
|
+
// but do not emit a duplicate parent node when the struct is local.
|
|
186
|
+
if (!(item.symbolType === "object" && concreteParents.has(item.name))) {
|
|
187
|
+
const location = topLocation(file, item, cwd, sourceCache);
|
|
188
|
+
out.push({ name, kind, file, line: location.line, sig: location.sig, lang: record.language });
|
|
189
|
+
}
|
|
190
|
+
for (const member of item.members ?? []) {
|
|
191
|
+
const memberKind = outlineKind(member, record.language);
|
|
192
|
+
out.push({
|
|
193
|
+
name: `${item.name}.${member.name}`,
|
|
194
|
+
kind: memberKind,
|
|
195
|
+
file,
|
|
196
|
+
line: member.range.start.line + 1,
|
|
197
|
+
sig: cleanSig(member.signature || `${memberKind} ${item.name}.${member.name}`),
|
|
198
|
+
lang: record.language,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return dedupe(out, (symbol) => `${symbol.name}@${symbol.file}`);
|
|
204
|
+
};
|
|
205
|
+
|
|
105
206
|
const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
106
207
|
const out: SymbolRec[] = [];
|
|
107
208
|
let file = "";
|
|
@@ -128,7 +229,8 @@ const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
|
128
229
|
name: `${top.name}.${name}`,
|
|
129
230
|
kind: kindOf(child[2]!),
|
|
130
231
|
file,
|
|
131
|
-
line: top.line,
|
|
232
|
+
line: top.line,
|
|
233
|
+
lineApproximate: true,
|
|
132
234
|
sig: `${kindOf(child[2]!)} ${top.name}.${name}`,
|
|
133
235
|
lang,
|
|
134
236
|
});
|
|
@@ -145,11 +247,19 @@ const parseOutlineText = (text: string, lang: string): SymbolRec[] => {
|
|
|
145
247
|
export const extractSymbols = (files: string[], cwd: string): SymbolRec[] => {
|
|
146
248
|
const out: SymbolRec[] = [];
|
|
147
249
|
for (const [lang, langFiles] of groupByLang(files)) {
|
|
250
|
+
const structured = outlineStructured(langFiles, lang, cwd);
|
|
251
|
+
if (structured) {
|
|
252
|
+
const parsed = parseStructuredOutline(structured, cwd);
|
|
253
|
+
if (parsed.length || structured.some((file) => file.items.length > 0)) {
|
|
254
|
+
pushAll(out, parsed);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
148
258
|
const text = outline(langFiles, lang, cwd);
|
|
149
259
|
if (!text.trim()) continue;
|
|
150
260
|
pushAll(out, parseOutlineText(text, lang));
|
|
151
261
|
}
|
|
152
|
-
return out.filter((
|
|
262
|
+
return out.filter((symbol) => symbol.file);
|
|
153
263
|
};
|
|
154
264
|
|
|
155
265
|
// --- imports ------------------------------------------------------------------
|
package/src/core/ops.ts
CHANGED
|
@@ -11,11 +11,11 @@ import { hasAstGrep } from "./astgrep.js";
|
|
|
11
11
|
import { assembleGraph, listFiles, loadFacts, type FileFacts } from "./build.js";
|
|
12
12
|
import { loadRepoRules } from "./anchors.js";
|
|
13
13
|
import { buildCsr, chebyshevVectors, chooseOrder, heatField, type Csr } from "./heat.js";
|
|
14
|
-
import { revealFoveated, revealGroups, tokenEstimate, type GroupLine } from "./render.js";
|
|
14
|
+
import { formatNodeLocation, revealFoveated, revealGroups, tokenEstimate, type GroupLine } from "./render.js";
|
|
15
15
|
import { getSession, TK_ORDER } from "./session.js";
|
|
16
16
|
import { detectBasins } from "./basins.js";
|
|
17
17
|
import { classifyLiteral, normalizeLiteral, buildJoinIndex, type JoinIndex } from "./join.js";
|
|
18
|
-
import type { Graph } from "./types.js";
|
|
18
|
+
import type { Graph, NodeRec } from "./types.js";
|
|
19
19
|
|
|
20
20
|
export interface OpResult {
|
|
21
21
|
text: string;
|
|
@@ -81,7 +81,87 @@ export const ensureState = (root: string): RepoState => {
|
|
|
81
81
|
|
|
82
82
|
// --- seed resolution ------------------------------------------------------------
|
|
83
83
|
|
|
84
|
-
export interface
|
|
84
|
+
export interface SeedSuggestion {
|
|
85
|
+
index: number;
|
|
86
|
+
name: string;
|
|
87
|
+
file: string;
|
|
88
|
+
line: number;
|
|
89
|
+
lineApproximate?: boolean;
|
|
90
|
+
score: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SeedResolution {
|
|
94
|
+
seeds: number[];
|
|
95
|
+
note: string;
|
|
96
|
+
suggestions: SeedSuggestion[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const QUERY_STOP_WORDS = new Set([
|
|
100
|
+
"a", "an", "and", "are", "do", "does", "find", "for", "happen", "happens", "how",
|
|
101
|
+
"in", "is", "of", "on", "please", "the", "this", "to", "what", "where", "which", "with",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const stemIdentifier = (term: string): string => {
|
|
105
|
+
if (term.length > 5 && term.endsWith("ing")) return term.slice(0, -3);
|
|
106
|
+
if (term.length > 4 && term.endsWith("ies")) return `${term.slice(0, -3)}y`;
|
|
107
|
+
if (term.length > 4 && /(ches|shes|sses|xes|zes)$/.test(term)) return term.slice(0, -2);
|
|
108
|
+
if (term.length > 3 && term.endsWith("s") && !term.endsWith("ss")) return term.slice(0, -1);
|
|
109
|
+
return term;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const identifierTerms = (value: string): string[] => {
|
|
113
|
+
const split = value
|
|
114
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
115
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.split(/[^a-z0-9]+/)
|
|
118
|
+
.filter((term) => term.length > 1 && !QUERY_STOP_WORDS.has(term))
|
|
119
|
+
.map(stemIdentifier)
|
|
120
|
+
.filter((term) => !QUERY_STOP_WORDS.has(term));
|
|
121
|
+
return [...new Set(split)];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const shortSymbolName = (name: string): string => name.slice(name.lastIndexOf(".") + 1);
|
|
125
|
+
|
|
126
|
+
const diceSimilarity = (a: string, b: string): number => {
|
|
127
|
+
if (a === b) return 1;
|
|
128
|
+
if (a.length < 2 || b.length < 2) return 0;
|
|
129
|
+
const left = new Map<string, number>();
|
|
130
|
+
for (let i = 0; i < a.length - 1; i++) {
|
|
131
|
+
const pair = a.slice(i, i + 2);
|
|
132
|
+
left.set(pair, (left.get(pair) ?? 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
let overlap = 0;
|
|
135
|
+
for (let i = 0; i < b.length - 1; i++) {
|
|
136
|
+
const pair = b.slice(i, i + 2);
|
|
137
|
+
const count = left.get(pair) ?? 0;
|
|
138
|
+
if (count > 0) {
|
|
139
|
+
overlap++;
|
|
140
|
+
left.set(pair, count - 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return (2 * overlap) / (a.length + b.length - 2);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const symbolSimilarity = (query: string, node: NodeRec): number => {
|
|
147
|
+
const queryTerms = identifierTerms(query);
|
|
148
|
+
const candidateTerms = identifierTerms(shortSymbolName(node.name));
|
|
149
|
+
const candidateSet = new Set(candidateTerms);
|
|
150
|
+
const shared = queryTerms.filter((term) => candidateSet.has(term)).length;
|
|
151
|
+
const coverage = queryTerms.length ? shared / queryTerms.length : 0;
|
|
152
|
+
const precision = candidateTerms.length ? shared / candidateTerms.length : 0;
|
|
153
|
+
const tokenScore = 0.72 * coverage + 0.28 * precision;
|
|
154
|
+
const charScore = diceSimilarity(queryTerms.join(""), candidateTerms.join(""));
|
|
155
|
+
return Math.max(tokenScore, charScore);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const sameIdentifierTerms = (query: string, name: string): boolean => {
|
|
159
|
+
const queryTerms = identifierTerms(query);
|
|
160
|
+
const candidateTerms = identifierTerms(shortSymbolName(name));
|
|
161
|
+
if (!queryTerms.length || queryTerms.length !== candidateTerms.length) return false;
|
|
162
|
+
const candidateSet = new Set(candidateTerms);
|
|
163
|
+
return queryTerms.every((term) => candidateSet.has(term));
|
|
164
|
+
};
|
|
85
165
|
|
|
86
166
|
export const resolveSeeds = (state: RepoState, query: string): SeedResolution => {
|
|
87
167
|
const g = state.graph;
|
|
@@ -134,6 +214,13 @@ export const resolveSeeds = (state: RepoState, query: string): SeedResolution =>
|
|
|
134
214
|
}
|
|
135
215
|
}
|
|
136
216
|
}
|
|
217
|
+
if (scored.size === 0) {
|
|
218
|
+
g.nodes.forEach((node, i) => {
|
|
219
|
+
if (node.kind !== "file" && node.kind !== "anchor" && sameIdentifierTerms(q, node.name)) {
|
|
220
|
+
bump(i, 0.7);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
137
224
|
// File path suffix (e.g. "web/api.ts").
|
|
138
225
|
for (const f of g.files) {
|
|
139
226
|
if (f === q || f.endsWith(`/${q}`)) {
|
|
@@ -148,7 +235,22 @@ export const resolveSeeds = (state: RepoState, query: string): SeedResolution =>
|
|
|
148
235
|
const seeds = ranked.map(([i]) => i);
|
|
149
236
|
const names = ranked.slice(0, 4).map(([i, s]) => `${g.nodes[i]!.name}${s < 1 ? "~" : ""}`);
|
|
150
237
|
const note = seeds.length ? `${seeds.length} seeds (${names.join(", ")}${seeds.length > 4 ? ", …" : ""})` : "no seeds matched";
|
|
151
|
-
|
|
238
|
+
const suggestions = seeds.length
|
|
239
|
+
? []
|
|
240
|
+
: g.nodes
|
|
241
|
+
.map((node, index) => ({ node, index, score: symbolSimilarity(q, node) }))
|
|
242
|
+
.filter(({ node, score }) => node.kind !== "file" && node.kind !== "anchor" && score >= 0.34)
|
|
243
|
+
.sort((a, b) => b.score - a.score || a.node.name.localeCompare(b.node.name) || a.node.file.localeCompare(b.node.file))
|
|
244
|
+
.slice(0, 5)
|
|
245
|
+
.map(({ node, index, score }) => ({
|
|
246
|
+
index,
|
|
247
|
+
name: node.name,
|
|
248
|
+
file: node.file,
|
|
249
|
+
line: node.line,
|
|
250
|
+
lineApproximate: node.lineApproximate,
|
|
251
|
+
score,
|
|
252
|
+
}));
|
|
253
|
+
return { seeds, note, suggestions };
|
|
152
254
|
};
|
|
153
255
|
|
|
154
256
|
const seedVector = (n: number, seeds: number[]): Float64Array => {
|
|
@@ -260,12 +362,38 @@ export const focus = (root: string, query: string, budget?: number): OpResult =>
|
|
|
260
362
|
const g = state.graph;
|
|
261
363
|
const session = getSession(root);
|
|
262
364
|
const B = clampBudget(budget, 2000);
|
|
263
|
-
const { seeds, note } = resolveSeeds(state, query);
|
|
365
|
+
const { seeds, note, suggestions } = resolveSeeds(state, query);
|
|
264
366
|
if (!seeds.length) {
|
|
367
|
+
const renderMiss = (count: number): string => {
|
|
368
|
+
const nearby = suggestions.slice(0, count).map((suggestion) => {
|
|
369
|
+
const node = g.nodes[suggestion.index]!;
|
|
370
|
+
return ` ? ${node.name} — ${formatNodeLocation(node)} — ${node.sig}`;
|
|
371
|
+
});
|
|
372
|
+
const guidance = suggestions.length
|
|
373
|
+
? "Retry fovea_focus with one of these names, a route path (/api/...), or a file path."
|
|
374
|
+
: "Try a symbol name, a route path (/api/...), or a file path. Run fovea_sketch for the map silhouette first.";
|
|
375
|
+
return [
|
|
376
|
+
`fovea focus "${query}": ${note}.`,
|
|
377
|
+
...(nearby.length ? ["Nearby symbols:", ...nearby] : []),
|
|
378
|
+
guidance,
|
|
379
|
+
].join("\n");
|
|
380
|
+
};
|
|
381
|
+
let shown = suggestions.length;
|
|
382
|
+
let text = renderMiss(shown);
|
|
383
|
+
while (shown > 0 && tokenEstimate(text) > B) text = renderMiss(--shown);
|
|
265
384
|
return {
|
|
266
|
-
text
|
|
267
|
-
tokens:
|
|
268
|
-
details: {
|
|
385
|
+
text,
|
|
386
|
+
tokens: tokenEstimate(text),
|
|
387
|
+
details: {
|
|
388
|
+
seeds: 0,
|
|
389
|
+
suggestions: suggestions.slice(0, shown).map(({ name, file, line, lineApproximate, score }) => ({
|
|
390
|
+
name,
|
|
391
|
+
file,
|
|
392
|
+
line,
|
|
393
|
+
lineApproximate,
|
|
394
|
+
score: Number(score.toFixed(3)),
|
|
395
|
+
})),
|
|
396
|
+
},
|
|
269
397
|
};
|
|
270
398
|
}
|
|
271
399
|
const key = `${state.version}:${[...seeds].sort((a, b) => a - b).join(",")}`;
|
|
@@ -280,6 +408,7 @@ export const focus = (root: string, query: string, budget?: number): OpResult =>
|
|
|
280
408
|
const fit = revealFoveated(g, field, {
|
|
281
409
|
header: `fovea focus "${query}" · ${note} · t=${t}`,
|
|
282
410
|
disclosed: session.disclosed,
|
|
411
|
+
seeds,
|
|
283
412
|
budget: B,
|
|
284
413
|
});
|
|
285
414
|
for (const id of fit.revealedIds) session.disclosed.add(id);
|
|
@@ -315,6 +444,7 @@ export const dwell = (root: string, factor?: number, budget?: number): OpResult
|
|
|
315
444
|
const fit = revealFoveated(g, field, {
|
|
316
445
|
header: `fovea dwell · t ${from}→${to} · delta`,
|
|
317
446
|
disclosed: session.disclosed,
|
|
447
|
+
seeds: session.seeds,
|
|
318
448
|
budget: B,
|
|
319
449
|
});
|
|
320
450
|
for (const id of fit.revealedIds) session.disclosed.add(id);
|
package/src/core/render.ts
CHANGED
|
@@ -4,13 +4,73 @@
|
|
|
4
4
|
// Budget conformance is a prefix fit: candidates sorted by heat, binary search
|
|
5
5
|
// on prefix length — aider's render-and-count loop generalized to a field.
|
|
6
6
|
|
|
7
|
-
import type { Graph } from "./types.js";
|
|
7
|
+
import type { Edge, EdgeKind, Graph, NodeRec } from "./types.js";
|
|
8
8
|
|
|
9
9
|
export const tokenEstimate = (text: string): number => Math.ceil(text.length / 4);
|
|
10
10
|
|
|
11
11
|
export const HOT_TIER = 0.3;
|
|
12
12
|
export const WARM_TIER = 0.02;
|
|
13
13
|
export const HEAT_EPS = 1e-9;
|
|
14
|
+
export const MAX_UNRELATED_WARM_PER_FILE = 4;
|
|
15
|
+
|
|
16
|
+
export const formatNodeLocation = (node: NodeRec): string => {
|
|
17
|
+
if (node.kind === "file" || node.line <= 0) return node.file;
|
|
18
|
+
if (node.lineApproximate) return `${node.file} (member line unavailable)`;
|
|
19
|
+
return `${node.file}:${node.line}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface DirectRelation {
|
|
23
|
+
kind: EdgeKind;
|
|
24
|
+
label: string;
|
|
25
|
+
priority: number;
|
|
26
|
+
weight: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RELATION_PRIORITY: Record<EdgeKind, number> = {
|
|
30
|
+
contains: 0,
|
|
31
|
+
cochange: 1,
|
|
32
|
+
imports: 2,
|
|
33
|
+
join: 3,
|
|
34
|
+
anchors: 4,
|
|
35
|
+
tests: 5,
|
|
36
|
+
inherits: 6,
|
|
37
|
+
invokes: 7,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const relationLabel = (edge: Edge, seedAtA: boolean, candidate: NodeRec): string => {
|
|
41
|
+
switch (edge.kind) {
|
|
42
|
+
case "invokes": return seedAtA ? "→ callee" : "← caller";
|
|
43
|
+
case "imports": return seedAtA ? "→ import" : "← importer";
|
|
44
|
+
case "tests": return seedAtA ? "→ subject" : "← test";
|
|
45
|
+
case "inherits": return seedAtA ? "→ parent" : "← subclass";
|
|
46
|
+
case "anchors": return seedAtA ? candidate.kind === "file" ? "→ feature file" : "→ handler" : "← route";
|
|
47
|
+
case "join": return "↔ shared literal";
|
|
48
|
+
case "cochange": return "↔ co-change";
|
|
49
|
+
case "contains": return seedAtA ? "◇ member" : "◇ file";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const directRelations = (g: Graph, seeds: ReadonlySet<number>): Map<number, DirectRelation> => {
|
|
54
|
+
const out = new Map<number, DirectRelation>();
|
|
55
|
+
for (const edge of g.edges) {
|
|
56
|
+
const aSeed = seeds.has(edge.a);
|
|
57
|
+
const bSeed = seeds.has(edge.b);
|
|
58
|
+
if (aSeed === bSeed) continue;
|
|
59
|
+
const node = aSeed ? edge.b : edge.a;
|
|
60
|
+
const relation: DirectRelation = {
|
|
61
|
+
kind: edge.kind,
|
|
62
|
+
label: relationLabel(edge, aSeed, g.nodes[node]!),
|
|
63
|
+
priority: RELATION_PRIORITY[edge.kind],
|
|
64
|
+
weight: edge.w,
|
|
65
|
+
};
|
|
66
|
+
const current = out.get(node);
|
|
67
|
+
if (!current || relation.priority > current.priority ||
|
|
68
|
+
(relation.priority === current.priority && relation.weight > current.weight)) {
|
|
69
|
+
out.set(node, relation);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
14
74
|
|
|
15
75
|
export interface FitResult {
|
|
16
76
|
text: string;
|
|
@@ -33,6 +93,7 @@ export interface RevealOptions {
|
|
|
33
93
|
header?: string;
|
|
34
94
|
disclosed?: ReadonlySet<string>;
|
|
35
95
|
exclude?: ReadonlySet<string>; // hard exclusion (e.g. seeds for impact)
|
|
96
|
+
seeds?: readonly number[];
|
|
36
97
|
budget: number;
|
|
37
98
|
maxCandidates?: number;
|
|
38
99
|
}
|
|
@@ -47,17 +108,25 @@ export const revealFoveated = (
|
|
|
47
108
|
if (vmax <= 0) {
|
|
48
109
|
return { text: `${opts.header ?? "fovea"}\n(nothing lit — field is zero)`, tokens: 0, shown: 0, suppressed: 0, litTotal: 0, truncated: false, revealedIds: [] };
|
|
49
110
|
}
|
|
111
|
+
const seedSet = new Set(opts.seeds ?? []);
|
|
112
|
+
const relations = directRelations(g, seedSet);
|
|
50
113
|
const candidates: number[] = [];
|
|
51
114
|
let suppressed = 0;
|
|
52
115
|
for (let i = 0; i < g.nodes.length; i++) {
|
|
53
116
|
const h = field[i]! / vmax;
|
|
54
|
-
|
|
117
|
+
const direct = relations.get(i);
|
|
118
|
+
if ((!direct || direct.kind === "contains") && (h < WARM_TIER * 0.1 || field[i]! < HEAT_EPS)) continue;
|
|
55
119
|
const id = g.nodes[i]!.id;
|
|
56
120
|
if (opts.exclude?.has(id)) continue;
|
|
57
121
|
if (opts.disclosed?.has(id)) { suppressed++; continue; }
|
|
58
122
|
candidates.push(i);
|
|
59
123
|
}
|
|
60
|
-
|
|
124
|
+
const byHeat = cmpNodes(g, field);
|
|
125
|
+
candidates.sort((a, b) => {
|
|
126
|
+
const aPriority = seedSet.has(a) ? 2 : relations.get(a)?.kind === "contains" ? 0 : relations.has(a) ? 1 : 0;
|
|
127
|
+
const bPriority = seedSet.has(b) ? 2 : relations.get(b)?.kind === "contains" ? 0 : relations.has(b) ? 1 : 0;
|
|
128
|
+
return bPriority - aPriority || byHeat(a, b);
|
|
129
|
+
});
|
|
61
130
|
const cap = opts.maxCandidates ?? 400;
|
|
62
131
|
const capped = candidates.slice(0, cap);
|
|
63
132
|
const litTotal = capped.length;
|
|
@@ -67,19 +136,39 @@ export const revealFoveated = (
|
|
|
67
136
|
// budget can shrink the periphery too; appending is byte-monotone, hence the
|
|
68
137
|
// binary search is exact and the output can never exceed the budget.
|
|
69
138
|
const glowCounts = new Map<string, number>();
|
|
139
|
+
const warmPerFile = new Map<string, number>();
|
|
70
140
|
const lines: string[] = [];
|
|
71
141
|
const ids: string[] = [];
|
|
72
142
|
for (const i of capped) {
|
|
73
|
-
const
|
|
143
|
+
const node = g.nodes[i]!;
|
|
74
144
|
const h = field[i]! / vmax;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
lines.push(
|
|
80
|
-
|
|
145
|
+
const relation = relations.get(i);
|
|
146
|
+
const semanticRelation = relation && relation.kind !== "contains" ? relation : undefined;
|
|
147
|
+
const context = seedSet.has(i) ? " [focus]" : relation ? ` [${relation.label}]` : "";
|
|
148
|
+
if (h >= HOT_TIER || seedSet.has(i)) {
|
|
149
|
+
lines.push(
|
|
150
|
+
node.kind === "file"
|
|
151
|
+
? `▒ ${node.file}${context}`
|
|
152
|
+
: node.kind === "anchor"
|
|
153
|
+
? `⚑ ${node.sig}${context}`
|
|
154
|
+
: `▲ ${formatNodeLocation(node)} ${node.sig}${context}`,
|
|
155
|
+
);
|
|
156
|
+
ids.push(node.id);
|
|
157
|
+
} else if (h >= WARM_TIER || semanticRelation) {
|
|
158
|
+
const warmCount = warmPerFile.get(node.file) ?? 0;
|
|
159
|
+
if (!semanticRelation && node.kind !== "file" && warmCount >= MAX_UNRELATED_WARM_PER_FILE) {
|
|
160
|
+
glowCounts.set(node.file, (glowCounts.get(node.file) ?? 0) + 1);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!semanticRelation && node.kind !== "file") warmPerFile.set(node.file, warmCount + 1);
|
|
164
|
+
lines.push(
|
|
165
|
+
semanticRelation
|
|
166
|
+
? ` ${semanticRelation.label} ${node.name} (${node.kind}) ${formatNodeLocation(node)}`
|
|
167
|
+
: ` · ${node.name} (${node.kind}) ${formatNodeLocation(node)}`,
|
|
168
|
+
);
|
|
169
|
+
ids.push(node.id);
|
|
81
170
|
} else {
|
|
82
|
-
glowCounts.set(
|
|
171
|
+
glowCounts.set(node.file, (glowCounts.get(node.file) ?? 0) + 1);
|
|
83
172
|
}
|
|
84
173
|
}
|
|
85
174
|
const glowLines = [...glowCounts.entries()]
|
|
@@ -89,11 +178,12 @@ export const revealFoveated = (
|
|
|
89
178
|
const individual = lines.length;
|
|
90
179
|
|
|
91
180
|
const header = `${opts.header ?? "fovea"} · lit ${litTotal}${suppressed ? `, ${suppressed} seen` : ""}`;
|
|
181
|
+
const collapsed = litTotal - individual;
|
|
92
182
|
const renderK = (k: number): string => {
|
|
93
183
|
const shownIndiv = Math.min(k, individual);
|
|
94
|
-
const remaining =
|
|
184
|
+
const remaining = collapsed + individual - shownIndiv;
|
|
95
185
|
const footer = remaining > 0
|
|
96
|
-
? `\n… ${remaining}
|
|
186
|
+
? `\n… ${remaining} low-acuity nodes remain collapsed or outside budget — fovea_dwell returns newly warmed neighbors`
|
|
97
187
|
: "";
|
|
98
188
|
return header + "\n" + items.slice(0, k).join("\n") + footer;
|
|
99
189
|
};
|
package/src/core/types.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type EdgeKind =
|
|
|
22
22
|
| "tests" // test file -> unit under test
|
|
23
23
|
| "join" // shared normalized literal (cross-language bridge)
|
|
24
24
|
| "anchors" // route anchor -> handler symbol (site-collapsed feature hub)
|
|
25
|
-
| "cochange"; // git-history co-change conductance
|
|
25
|
+
| "cochange"; // git-history co-change conductance
|
|
26
26
|
|
|
27
27
|
export interface NodeRec {
|
|
28
28
|
id: string; // stable identity: "name@file" (methods: "Type.name@file")
|
|
@@ -30,6 +30,7 @@ export interface NodeRec {
|
|
|
30
30
|
kind: NodeKind;
|
|
31
31
|
file: string; // repo-relative path
|
|
32
32
|
line: number; // 1-indexed
|
|
33
|
+
lineApproximate?: boolean; // legacy outlines only know the enclosing declaration
|
|
33
34
|
sig: string; // one-line signature for foveated rendering
|
|
34
35
|
lang: string; // ast-grep language name, or "config" / "text"
|
|
35
36
|
}
|
|
@@ -65,6 +66,7 @@ export interface SymbolRec {
|
|
|
65
66
|
kind: NodeKind;
|
|
66
67
|
file: string;
|
|
67
68
|
line: number;
|
|
69
|
+
lineApproximate?: boolean;
|
|
68
70
|
sig: string;
|
|
69
71
|
lang: string;
|
|
70
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -156,9 +156,9 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
156
156
|
name: "fovea_focus",
|
|
157
157
|
label: "Fovea Focus",
|
|
158
158
|
description:
|
|
159
|
-
"Center the fovea on a query (symbol name, route path like /api/users/{id}, env key, or file). Returns
|
|
159
|
+
"Center the fovea on a query (symbol name or close spelling, route path like /api/users/{id}, env key, or file). Returns exact hot signatures, typed direct relationships, warm one-liners, and a collapsed periphery. Misses include nearby symbols. Sets the session focus that fovea_dwell deepens; already-shown nodes are suppressed.",
|
|
160
160
|
parameters: Type.Object({
|
|
161
|
-
query: Type.String({ description: "Symbol name, route path, env key, or repo-relative file path." }),
|
|
161
|
+
query: Type.String({ description: "Symbol name or close spelling, route path, env key, or repo-relative file path." }),
|
|
162
162
|
root: RootParam,
|
|
163
163
|
maxTokens: BudgetParam,
|
|
164
164
|
}),
|
|
@@ -177,7 +177,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
177
177
|
name: "fovea_dwell",
|
|
178
178
|
label: "Fovea Dwell",
|
|
179
179
|
description:
|
|
180
|
-
"Let the current focus diffuse longer (heat time t grows, default x2) and return only
|
|
180
|
+
"Let the current focus diffuse longer (heat time t grows, default x2) and return only newly warmed neighbors. Use after fovea_focus when its footer reports a remaining low-acuity periphery.",
|
|
181
181
|
parameters: Type.Object({
|
|
182
182
|
factor: Type.Optional(Type.Number({ description: "Multiply diffusion time by this (default 2).", minimum: 1.1, maximum: 16 })),
|
|
183
183
|
root: RootParam,
|