pi-fovea 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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-liners a hop away, a skeleton of the rest.
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,9 +26,33 @@ 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 nodes as signatures, neighbors as one-liners |
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
+ | `grep` *(default override)* | where does this concept lead? | the same graph-backed focus through grep's familiar `pattern/path/glob/...` signature |
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
+
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`.
37
+
38
+ ### pi-fabric
39
+
40
+ Captured extension tools live under Fabric's `extensions` provider. Use the direct proxy when the action is known:
41
+
42
+ ```ts
43
+ const result = await extensions.fovea_focus({ query: "CreateUserHandler", maxTokens: 2000 });
44
+ return result.text;
45
+ ```
46
+
47
+ For dynamic discovery, pass an object to `tools.search` and keep the returned namespaced ref:
48
+
49
+ ```ts
50
+ const [action] = await tools.search({ query: "fovea_focus", limit: 5 });
51
+ if (!action) return "Fovea is not captured";
52
+ return tools.call({ ref: action.ref, args: { query: "CreateUserHandler" } });
53
+ ```
54
+
55
+ The stable explicit ref is `extensions.fovea_focus`, not bare `fovea_focus` or `fovea.fovea_focus`.
32
56
 
33
57
  Two slash commands on top:
34
58
 
@@ -99,6 +123,7 @@ Global settings live in `~/.pi/agent/fovea.json`. A trusted repo-level override
99
123
  | `sync.ackClean` | `false` | toast after clean structural turns |
100
124
  | `sync.warmFileThreshold` | `2` | warmed files unseen by the model that justify turning red |
101
125
  | `tools.defaultBudget` | `2000` | fallback maxTokens for the fovea_* tools |
126
+ | `tools.replaceGrep` | `true` | replace Pi's grep slot with graph-backed Fovea navigation |
102
127
 
103
128
  ## How routes are found
104
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 come back with full signatures; warm neighbors as one-liners; the periphery stays collapsed. Already-shown nodes are suppressed, so repeated focus calls stay cheap.
14
- 3. **`fovea_dwell`** — optional second look. If a focus footer says more nodes are lit below the token threshold, dwell (diffusion time ×2) surfaces exactly those newcomers.
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.
@@ -32,8 +32,9 @@ Sync is **mutation-path agnostic**: pi's edit/write tools, a pi-fabric `fabric_e
32
32
 
33
33
  When writing or editing code **inside a `fabric_exec` program**, the fovea tools exist but the fabric sandbox has no built-in knowledge of them (it lazy-loads tools). Key points:
34
34
 
35
- - Inside `fabric_exec`, discover them once with `await tools.search("fovea")` and call them through `tools.call({ ref, args })` — e.g. `{ ref: "fovea_focus", args: { query: "CreateUserHandler" } }`. They are ordinary pi tools; there is no fabric-specific wrapper.
36
- - Prefer a single `fovea_impact` call over hand-rolled grep fan-outs when computing what an edit touches the graph already resolved imports/calls across Go, TypeScript, Python, and Java.
35
+ - Inside `fabric_exec`, captured extension tools use the `extensions` provider. For a known action call `await extensions.fovea_focus({ query: "CreateUserHandler", maxTokens: 6000 })`.
36
+ - For dynamic discovery, use `const hits = await tools.search({ query: "fovea_focus" })`, then call the returned namespaced ref with `tools.call({ ref: hits[0].ref, args: { query: "CreateUserHandler", maxTokens: 6000 } })`. The stable explicit ref is `extensions.fovea_focus`; bare `fovea_focus` and `fovea.fovea_focus` are invalid.
37
+ - Prefer a single `extensions.fovea_impact(...)` call over hand-rolled grep fan-outs when computing what an edit touches — the graph already resolved imports/calls across Go, TypeScript, Python, and Java.
37
38
  - Any file mutation performed by the program (including `pi.edit`/`pi.write` calls inside the sandbox) is picked up by turn sync automatically, so post-edit verification does not need a re-sketch.
38
39
  - The sketch `details` field carries counts (`files`, `nodes`, `anchors`); the hot-node list is the graph's highest-value entry points. On an unfamiliar repo, fetch it once and reuse instead of rediscovering entry points per call.
39
40
 
@@ -43,4 +44,4 @@ The same engine runs headlessly as the `fovea` binary (repo root scan, plus JSON
43
44
 
44
45
  ## Settings
45
46
 
46
- `/fovea settings` in the TUI, or `fovea.config.json` at repo or user level. Relevant knobs: `sync.enabled`, `sync.budget`, `sync.warmFileThreshold` (files that must escape before a red sync fires), `tools.defaultBudget`.
47
+ `/fovea settings` in the TUI, or `fovea.json` under `~/.pi/agent/` or a trusted repo's `.pi/` directory. Relevant knobs: `sync.enabled`, `sync.budget`, `sync.warmFileThreshold` (files that must escape before a red sync fires), `tools.defaultBudget`, and `tools.replaceGrep` (default on; installs a grep-compatible Fovea override and reloads extensions).
@@ -83,10 +83,52 @@ export const groupByLang = (files: string[]): Map<string, string[]> => {
83
83
  return m;
84
84
  };
85
85
 
86
- // `ast-grep outline` uniform symbol source across languages. Text format:
87
- // <file>
88
- // <line>: <signature source line>
89
- // <kind>: <name> (children: methods/fields, indented)
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 = 6; // bump when extractor semantics change
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.
@@ -22,6 +22,8 @@ interface FoveaSyncConfig {
22
22
  interface FoveaToolsConfig {
23
23
  /** Budget applied when a fovea_* tool call omits maxTokens. */
24
24
  defaultBudget: number;
25
+ /** Replace Pi's grep slot with a graph-backed fovea_focus adapter. */
26
+ replaceGrep: boolean;
25
27
  }
26
28
 
27
29
  export interface FoveaConfig {
@@ -38,6 +40,7 @@ export const DEFAULT_FOVEA_CONFIG: FoveaConfig = {
38
40
  },
39
41
  tools: {
40
42
  defaultBudget: 2000,
43
+ replaceGrep: true,
41
44
  },
42
45
  };
43
46
 
@@ -81,6 +84,7 @@ const applyPartial = (base: FoveaConfig, partial: unknown): FoveaConfig => {
81
84
  },
82
85
  tools: {
83
86
  defaultBudget: intValue("tools.defaultBudget", tools.defaultBudget, base.tools.defaultBudget),
87
+ replaceGrep: boolValue(tools.replaceGrep, base.tools.replaceGrep),
84
88
  },
85
89
  };
86
90
  };
@@ -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, // outline children carry no line; point at parent
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((s) => s.file);
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 SeedResolution { seeds: number[]; note: string; }
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
- return { seeds, note };
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: `fovea focus "${query}": ${note}. Try a symbol name, a route path (/api/...), or a file path. Run fovea_sketch for the map silhouette first.`,
267
- tokens: 0,
268
- details: { seeds: 0 },
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);
@@ -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
- if (h < WARM_TIER * 0.1 || field[i]! < HEAT_EPS) continue;
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
- candidates.sort(cmpNodes(g, field));
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 n = g.nodes[i]!;
143
+ const node = g.nodes[i]!;
74
144
  const h = field[i]! / vmax;
75
- if (h >= HOT_TIER) {
76
- lines.push(n.kind === "file" ? `▒ ${n.file}` : n.kind === "anchor" ? `⚑ ${n.sig}` : `▲ ${n.file}:${n.line} ${n.sig}`);
77
- ids.push(n.id);
78
- } else if (h >= WARM_TIER) {
79
- lines.push(` · ${n.name} (${n.kind}) ${n.file}:${n.line}`);
80
- ids.push(n.id);
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(n.file, (glowCounts.get(n.file) ?? 0) + 1);
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 = litTotal - shownIndiv;
184
+ const remaining = collapsed + individual - shownIndiv;
95
185
  const footer = remaining > 0
96
- ? `\n… ${remaining} lit below threshold call fovea_dwell to expand (t grows, periphery sharpens)`
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 // route anchor -> handler symbol
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
@@ -17,6 +17,15 @@ const BudgetParam = Type.Optional(
17
17
  const RootParam = Type.Optional(
18
18
  Type.String({ description: "Repo root to map. Defaults to the session working directory." }),
19
19
  );
20
+ const GrepParams = Type.Object({
21
+ pattern: Type.String({ description: "Symbol, route, environment key, or file query for the Fovea code graph." }),
22
+ path: Type.Optional(Type.String({ description: "Compatibility path hint. Used as a fallback graph seed when pattern finds no node." })),
23
+ glob: Type.Optional(Type.String({ description: "Accepted for grep-call compatibility; Fovea navigation is graph-based rather than glob-filtered." })),
24
+ ignoreCase: Type.Optional(Type.Boolean({ description: "Accepted for grep-call compatibility; Fovea symbol matching is already case-insensitive." })),
25
+ literal: Type.Optional(Type.Boolean({ description: "Accepted for grep-call compatibility; the pattern is interpreted as a graph query." })),
26
+ context: Type.Optional(Type.Number({ description: "Accepted for grep-call compatibility; graph neighbors replace line context." })),
27
+ limit: Type.Optional(Type.Number({ description: "Accepted for grep-call compatibility; output is controlled by tools.defaultBudget." })),
28
+ });
20
29
 
21
30
  const text = (s: string) => ({ type: "text" as const, text: s });
22
31
 
@@ -31,11 +40,54 @@ export default function fovea(pi: ExtensionAPI) {
31
40
  return cfg;
32
41
  };
33
42
 
34
- pi.on("session_start", async (event) => {
43
+ let grepOverrideRegistered = false;
44
+ const registerGrepOverride = (): void => {
45
+ if (grepOverrideRegistered) return;
46
+ grepOverrideRegistered = true;
47
+ pi.registerTool({
48
+ name: "grep",
49
+ label: "grep (Fovea)",
50
+ description:
51
+ "Navigate the pi-fovea code graph through grep's familiar argument shape. Finds symbols, routes, environment keys, files, and their warm dependencies; it does not perform literal line matching. Use bash with rg only when exact text or regex matches are required.",
52
+ promptSnippet: "Navigate the Fovea code graph with a grep-compatible query",
53
+ promptGuidelines: [
54
+ "Use grep for graph-backed repository navigation before exact text search; when the Fovea override is active, grep centers the code graph on pattern and returns warm dependencies rather than matching lines.",
55
+ "Use bash with rg only when an exact literal or regular-expression text match is required after the Fovea-backed grep result.",
56
+ ],
57
+ parameters: GrepParams,
58
+ async execute(_id, params, _signal, _onUpdate, ctx) {
59
+ const root = ctx.cwd;
60
+ const budget = configFor(root, ctx.isProjectTrusted()).tools.defaultBudget;
61
+ const pattern = params.pattern.trim();
62
+ const pathHint = params.path?.replace(/^@/, "").trim();
63
+ let query = pattern || pathHint || params.pattern;
64
+ try {
65
+ let result = focus(root, query, budget);
66
+ if (Number(result.details.seeds ?? 0) === 0 && pathHint && pathHint !== "." && pathHint !== query) {
67
+ query = pathHint;
68
+ result = focus(root, query, budget);
69
+ }
70
+ return {
71
+ content: [text(result.text.replace(/^fovea focus/, "fovea grep"))],
72
+ details: { ...result.details, backend: "fovea", query },
73
+ };
74
+ } catch (error) {
75
+ return {
76
+ content: [text(String(error instanceof Error ? error.message : error))],
77
+ details: { backend: "fovea", query },
78
+ isError: true,
79
+ };
80
+ }
81
+ },
82
+ });
83
+ };
84
+
85
+ pi.on("session_start", async (event, ctx) => {
35
86
  if (event.reason === "new" || event.reason === "fork") {
36
87
  resetSessions();
37
88
  resetSyncBaselines();
38
89
  }
90
+ if (configFor(ctx.cwd, ctx.isProjectTrusted()).tools.replaceGrep) registerGrepOverride();
39
91
  });
40
92
 
41
93
  // Turn-sync loop. The tracker below is a hint accumulator only: pi's
@@ -104,9 +156,9 @@ export default function fovea(pi: ExtensionAPI) {
104
156
  name: "fovea_focus",
105
157
  label: "Fovea Focus",
106
158
  description:
107
- "Center the fovea on a query (symbol name, route path like /api/users/{id}, env key, or file). Returns a foveated field: hot nodes with full signatures, warm nodes as one-liners, the periphery collapsed. Sets the session focus that fovea_dwell deepens. Only new information is returned — already-shown nodes are suppressed.",
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.",
108
160
  parameters: Type.Object({
109
- 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." }),
110
162
  root: RootParam,
111
163
  maxTokens: BudgetParam,
112
164
  }),
@@ -125,7 +177,7 @@ export default function fovea(pi: ExtensionAPI) {
125
177
  name: "fovea_dwell",
126
178
  label: "Fovea Dwell",
127
179
  description:
128
- "Let the current focus diffuse longer (heat time t grows, default x2) and return only the newly-luminous periphery. Use after fovea_focus when the footer says more nodes are lit below threshold.",
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.",
129
181
  parameters: Type.Object({
130
182
  factor: Type.Optional(Type.Number({ description: "Multiply diffusion time by this (default 2).", minimum: 1.1, maximum: 16 })),
131
183
  root: RootParam,
@@ -179,14 +231,18 @@ export default function fovea(pi: ExtensionAPI) {
179
231
  handler: async (args, ctx) => {
180
232
  const sub = args.trim().split(/\s+/)[0] ?? "status";
181
233
  if (sub === "settings") {
182
- await openFoveaSettings(ctx, { onConfigApplied: () => configs.clear() });
234
+ const result = await openFoveaSettings(ctx, { onConfigApplied: () => configs.clear() });
235
+ if (result.grepRegistrationChanged) {
236
+ ctx.ui.notify("Reloading extensions to apply the grep tool change…", "info");
237
+ await ctx.reload();
238
+ }
183
239
  return;
184
240
  }
185
241
  try {
186
242
  const s = sketch(ctx.cwd, 256);
187
243
  const cfg = configFor(ctx.cwd, ctx.isProjectTrusted());
188
244
  ctx.ui.notify(
189
- `pi-fovea: ${s.details.files ?? 0} files, ${s.details.nodes ?? 0} nodes, ${s.details.anchors ?? 0} anchors · sync ${cfg.sync.enabled ? "on" : "off"}`,
245
+ `pi-fovea: ${s.details.files ?? 0} files, ${s.details.nodes ?? 0} nodes, ${s.details.anchors ?? 0} anchors · sync ${cfg.sync.enabled ? "on" : "off"} · grep ${cfg.tools.replaceGrep ? "fovea" : "native"}`,
190
246
  "info",
191
247
  );
192
248
  } catch (e) {
@@ -194,6 +194,11 @@ const buildItems = (
194
194
  "How many undisclosed files must warm up during sync to justify a red message. Higher = fewer interruptions; route anchor shifts always escalate.",
195
195
  submenu: numericSubmenu(theme, THRESHOLDS, "Warm file threshold", "Disclosed-file warming count that escalates sync to red."),
196
196
  }),
197
+ setting("tools.replaceGrep", "Replace grep", config.tools.replaceGrep ? "true" : "false", {
198
+ description:
199
+ "Register a grep-compatible tool backed by fovea_focus instead of literal text search. Default on; changing it reloads extensions so Pi and Fabric see the new tool slot.",
200
+ values: BOOLEANS,
201
+ }),
197
202
  setting("tools.defaultBudget", "Default tool budget", String(config.tools.defaultBudget), {
198
203
  description: "Token budget applied when a fovea_* tool call omits maxTokens.",
199
204
  submenu: numericSubmenu(theme, BUDGETS, "Default tool budget", "Fallback maxTokens for fovea tools."),
@@ -206,13 +211,17 @@ export interface FoveaSettingsDeps {
206
211
  onConfigApplied?: () => void;
207
212
  }
208
213
 
214
+ export interface FoveaSettingsResult {
215
+ grepRegistrationChanged: boolean;
216
+ }
217
+
209
218
  export const openFoveaSettings = async (
210
219
  context: ExtensionContext,
211
220
  deps: FoveaSettingsDeps = {},
212
- ): Promise<void> => {
221
+ ): Promise<FoveaSettingsResult> => {
213
222
  if (context.mode !== "tui") {
214
223
  context.ui.notify("Fovea settings are available in TUI mode", "warning");
215
- return;
224
+ return { grepRegistrationChanged: false };
216
225
  }
217
226
  const agentDir = getAgentDir();
218
227
  const scopes = {
@@ -221,6 +230,7 @@ export const openFoveaSettings = async (
221
230
  projectTrusted: context.isProjectTrusted(),
222
231
  };
223
232
  let config = loadFoveaConfig(scopes);
233
+ const initialReplaceGrep = config.tools.replaceGrep;
224
234
  let dirty = false;
225
235
 
226
236
  const apply = (id: string, value: unknown): void => {
@@ -249,6 +259,7 @@ export const openFoveaSettings = async (
249
259
  });
250
260
 
251
261
  if (dirty) context.ui.notify("Fovea settings saved.", "info");
262
+ return { grepRegistrationChanged: config.tools.replaceGrep !== initialReplaceGrep };
252
263
  };
253
264
 
254
265
  // Local in-place merge so subsequent edits in the same overlay start from the