pi-fovea 0.4.0 → 0.5.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
@@ -19,21 +19,31 @@ _See the whole repo on every prompt, sharp where you work and cheap everywhere e
19
19
 
20
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
- 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.
22
+ At agent start Fovea establishes or checks its semantic baseline, so out-of-band edits made while Pi was idle enter context before the first model call. After each assistant turn it re-syncs again. Detection does not trust tool events: edits made by Pi tools, fabric_exec, bash, subagents, or an editor land identically, while comment- and formatting-only drift stays silent. A meaningful post-turn change is delivered as a **steer**, and Fovea triggers the continuation itself if the agent would otherwise wait.
23
+
24
+ ## When not to reach for fovea
25
+
26
+ A map costs more than the territory when the territory is small: on repos of a
27
+ few dozen files, reading the files directly beats sketching them. Fovea pays
28
+ for itself when the working set exceeds context — cross-language monorepos,
29
+ unfamiliar long-lived codebases, routes woven through config and client code.
30
+ It narrows **what** you read to the suggested windows; it does not replace
31
+ reading them, the project's own format/lint/typecheck/test commands, or CI as
32
+ the final verification layer.
23
33
 
24
34
  ## What the model gets
25
35
 
26
36
  | Command | Ask | Answer |
27
37
  |---|---|---|
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: exact hot signatures, typed direct relationships, then warm one-liners |
30
- | `fovea_dwell` | what else? | diffuses the field one step further and returns the delta |
38
+ | `fovea_sketch` | where is everything? | production-first silhouette; test and fixture architecture stays collapsed |
39
+ | `fovea_focus` | what is this? | exact matches, typed relationships, suggested reads, optional source scopes, and deterministic `fresh` views |
40
+ | `fovea_dwell` | what else? | widens the current focus and returns newly relevant neighbors |
31
41
  | `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 |
42
+ | `grep` *(default hybrid)* | graph or text? | bare identifiers, qualified symbols, repo paths, and routes use Fovea; search options and obvious regex retain native grep |
33
43
 
34
44
  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
45
 
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`.
46
+ The **Hybrid grep** toggle is on by default. `grep({ pattern: "CreateUser" })`, `grep({ pattern: "Controller.create" })`, and route paths can navigate the graph. Calls with text-search options and obvious regexes delegate to Pi's native grep unchanged; a graph miss falls back to native text, and a graph error (for example a broken ast-grep) degrades the same way with a one-line note marking the result as native. Disable the toggle for a purely native slot. Changing it reloads extensions so Pi and pi-fabric capture the same behavior.
37
47
 
38
48
  ### pi-fabric
39
49
 
@@ -54,10 +64,12 @@ return tools.call({ ref: action.ref, args: { query: "CreateUserHandler" } });
54
64
 
55
65
  The stable explicit ref is `extensions.fovea_focus`, not bare `fovea_focus` or `fovea.fovea_focus`.
56
66
 
57
- Two slash commands on top:
67
+ Runtime slash controls:
58
68
 
59
- - `/fovea status` for graph stats and sync state
60
- - `/fovea settings` for an overlay in your TUI, styled after pi-fabric's `/fabric settings`
69
+ - `/fovea status` for loaded versions, graph coverage, and active modes
70
+ - `/fovea settings` for a TUI configuration overlay
71
+ - `/fovea reset` for a fresh focus and sync baseline
72
+ - `/fovea reload` to activate updated extension source
61
73
 
62
74
  ## Install
63
75
 
@@ -99,14 +111,18 @@ fovea status /path/to/repo
99
111
 
100
112
  ## Turn sync
101
113
 
102
- Turn sync is on by default. After every assistant turn the graph is re-synced against your edits: unchanged files cost nothing because every parsed fact sits behind its content hash. The verdict is **green** or **red**:
114
+ Continuous sync is on by default. Before an agent starts, Fovea establishes its baseline or injects any out-of-band drift before the first model call. After every assistant turn it compares extracted symbols, calls, imports, literals, and anchors again. Content hashes keep the unchanged fast path cheap, while comment- and formatting-only edits do not wake the model.
115
+
116
+ A meaningful change found before agent start is injected directly into that run. A post-turn route or dependency change is sent with `deliverAs: "steer"`; if the agent would otherwise settle, `triggerTurn` starts the continuation automatically. The compact update names directly changed files, route deltas, newly relevant files, causal channels such as calls, imports, shared literals, tests, or co-change history, and a suggested next focus probe so the update continues graph navigation. Clean turns remain silent unless `sync.ackClean` is enabled.
103
117
 
104
- - **green**: silence in the model's context. A clean-toast shows only if you enable `sync.ackClean`.
105
- - **red**: a capped custom message naming route anchors that appeared or disappeared, plus files warmed by the edit cascade that the model has not focused on yet.
118
+ Runtime controls:
106
119
 
107
- The first sync seeds the baseline. The first drift after it calibrates the warm neighborhood, so a steady feature cone stays quiet. Sub sequent drift turns red.
120
+ - `/fovea status` — loaded package/ast-grep versions, indexed coverage, anchor scopes, sync and grep modes.
121
+ - `/fovea reset` — clear focus disclosure/depth and establish a fresh sync baseline.
122
+ - `/fovea reload` — hot-reload extensions and activate newly installed source.
123
+ - `/fovea settings` — configure sync, budgets, and hybrid grep.
108
124
 
109
- Turn it off per repo or globally: `/fovea settings` → Turn sync, or
125
+ Turn sync off per repo or globally through settings, or with:
110
126
 
111
127
  ```sh
112
128
  FOVEA_TURN_SYNC=off pi
@@ -118,12 +134,12 @@ Global settings live in `~/.pi/agent/fovea.json`. A trusted repo-level override
118
134
 
119
135
  | Key | Default | Meaning |
120
136
  | --- | :-----: | ------- |
121
- | `sync.enabled` | `true` | the turn-sync loop |
122
- | `sync.budget` | `1024` | token cap for the red report seen by the model |
137
+ | `sync.enabled` | `true` | pre-agent and post-turn continuous sync |
138
+ | `sync.budget` | `1024` | token cap for proactive steering context |
123
139
  | `sync.ackClean` | `false` | toast after clean structural turns |
124
- | `sync.warmFileThreshold` | `2` | warmed files unseen by the model that justify turning red |
140
+ | `sync.warmFileThreshold` | `2` | newly relevant files that justify proactive model steering |
125
141
  | `tools.defaultBudget` | `2000` | fallback maxTokens for the fovea_* tools |
126
- | `tools.replaceGrep` | `true` | replace Pi's grep slot with graph-backed Fovea navigation |
142
+ | `tools.replaceGrep` | `true` | install hybrid native-text / bare-query graph grep |
127
143
 
128
144
  ## How routes are found
129
145
 
@@ -172,7 +188,7 @@ $$
172
188
  v(t) = e^{-tL} \cdot s \quad \text{with} \quad L = I - D^{-1/2} W D^{-1/2}
173
189
  $$
174
190
 
175
- The four tools are the same operator at four timescales: sketch at $t=16$ with hub and anchor seeds, focus at $t=4$ with your query as seed, dwell doubling $t$ per call with a disclosed-set delta, and impact using the changed files as seed.
191
+ The four tools are the same operator at four timescales: sketch at $t=16$ with production hub and anchor seeds, focus at $t=2$ with your query as seed, dwell doubling $t$ within that focus, and impact using changed files as seeds. Changing focus resets to the sharp timescale and its own disclosure scope.
176
192
 
177
193
  The kernel is evaluated with a Chebyshev expansion. Rescale $M = L - I$ so the spectrum sits in $[-1,1]$; then with $T_k$ the Chebyshev polynomials and $I_k$ the modified Bessel functions:
178
194
 
@@ -190,7 +206,7 @@ $$
190
206
 
191
207
  Measured against eight cloned projects, corpus junk sits below $\hat{p} \approx 0.27$ and real route shapes above $\hat{p} \approx 0.75$. The cutoff stays mid-cliff regardless of repo size.
192
208
 
193
- Lineage: spectral-graph wavelets evaluated by shared Chebyshev recurrence, progressive image coding where the budget is a bitrate over significance-ordered coefficients, and foveated rendering. Aider's PageRank repo map is the fixed-timescale special case of this field. The full walkthrough of conductance tiers, specificity bridges, hub gravity, and basins lives in [docs/heat-diffusion.md](docs/heat-diffusion.md).
209
+ Lineage: spectral-graph wavelets evaluated by shared Chebyshev recurrence, progressive image coding where the budget is a bitrate over significance-ordered coefficients, and foveated rendering. Aider's PageRank repo map is the fixed-timescale special case of this field. The full walkthrough of conductance tiers, specificity bridges, hub gravity, and inferred regions lives in [docs/heat-diffusion.md](docs/heat-diffusion.md).
194
210
 
195
211
  ## Languages
196
212
 
@@ -208,4 +224,8 @@ pnpm run bench # rate–distortion bench against ../pi-fabric
208
224
 
209
225
  pi loads the extension straight from `src/` via jiti; there is no build step. Per-repo caches live in `$TMPDIR` behind per-file content sha1 hashes, and only dirty files re-run ast-grep. Bump `CACHE_VERSION` in `src/core/build.ts` whenever extractor semantics change.
210
226
 
227
+ ## Acknowledgments
228
+
229
+ Thanks to [Alp](https://www.patreon.com/cw/alpderps), the original user whose request for a better LSP extension started this project.
230
+
211
231
  [MIT](LICENSE).
package/cli.ts CHANGED
@@ -56,7 +56,11 @@ try {
56
56
  let out = "";
57
57
  if (cmd === "status") {
58
58
  const s = sketch(rootAt(0), 256);
59
- out = `${s.details.files} files, ${s.details.nodes} nodes, ${s.details.anchors} anchors`;
59
+ const testAnchors = Number(s.details.testAnchors ?? 0);
60
+ const failed = Number(s.details.extractionFailures ?? 0);
61
+ out = `${s.details.files} files, ${s.details.nodes} symbols, ${s.details.productionAnchors ?? s.details.anchors} production anchors` +
62
+ (testAnchors ? `, ${testAnchors} test/fixture anchors collapsed` : "") +
63
+ (failed ? `, !${failed} files failed extraction` : "");
60
64
  } else if (cmd === "sketch") {
61
65
  const root = rootAt(0);
62
66
  const B = numAt(pos[0] === root && pos.length > 1 ? 1 : 0) ?? 1400;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -9,22 +9,23 @@ pi-fovea maintains a cross-language code graph of the working repository — rou
9
9
 
10
10
  ## The loop
11
11
 
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 (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
- 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.
12
+ 1. **`fovea_sketch`** — production-first silhouette. Shipped routes and source regions lead; test and fixture architecture is collapsed. Start here in an unfamiliar repo. ~256–1024 tokens.
13
+ 2. **`fovea_focus` `<query>`** — point at a symbol name (close spellings work), route, env key, or file. The active seed and direct relationships always remain visible; previously seen periphery is suppressed only within that focus. A different focus resets to sharp context. Use `path`, `language`, or `kind` to scope output and `fresh: true` for a reproducible full view. Structured details include nodes and suggested read windows.
14
+ 3. **`fovea_dwell`** — optional second look. If focus says more results remain, dwell widens only the current focus and returns newly relevant neighbors.
15
+ 4. **`fovea_impact`** — blast radius. Seed with repo-relative `files`, symbols, uncommitted changes, or a PR `base`. Output is likely review order with causal channels (calls, imports, literals, routes, tests, inheritance, co-change).
16
16
 
17
17
  All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
18
18
 
19
19
  ## Working rules
20
20
 
21
- - **Never bulk-read to find things.** Read what focus surfaced; let the graph answer "where is X" and "what uses X" instead of spawning searches.
21
+ - **Do not bulk-read to discover structure.** Focus first, then read its suggested ranges. Native grep semantics remain available whenever grep receives path/glob/literal/context/limit options or an obvious regex; unresolved graph queries fall back to native text.
22
22
  - **Impact before destructive edits.** One `fovea_impact` call is cheaper than rediscovering dependents by breaking them.
23
23
  - **Sketch is the safe opening bid.** If unsure, pay for a sketch; it almost never exceeds a few hundred tokens.
24
+ - **Skip the map when the repo is tiny.** A few dozen files are cheaper to bulk-read than to sketch; Fovea's value grows with repos larger than context. Either way it points at windows to read — the project's own format/lint/typecheck/test commands and CI remain the final verification layer.
24
25
 
25
26
  ## Turn sync
26
27
 
27
- After each assistant turn, pi-fovea diffs content hashes against its baseline. If edits moved route anchors or warmed files outside the session's disclosed set, a `[fovea turn sync]` message arrives in the next turn with the delta; otherwise everything stays silent. Treat that message as ground truth about mid-session state changes.
28
+ Before an agent starts, pi-fovea establishes its baseline or injects out-of-band semantic drift into that run. After each assistant turn it compares again; meaningful route/dependency drift is delivered as a **steer**, and Fovea triggers a continuation if the agent would otherwise stop. Treat its changed files, route deltas, and causal channels as continuous task context. Comment- and formatting-only edits stay silent.
28
29
 
29
30
  Sync is **mutation-path agnostic**: pi's edit/write tools, a pi-fabric `fabric_exec` program's inner `pi.edit`, a bash heredoc, a subagent, or an editor save outside the session all register identically. Content hashes are the source of truth; tool events are not consulted for detection. In repos with no `.git` directory this is also the only drift signal — do not fall back to `git status` assumptions.
30
31
 
@@ -36,7 +37,7 @@ When writing or editing code **inside a `fabric_exec` program**, the fovea tools
36
37
  - 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
38
  - 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.
38
39
  - 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.
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.
40
+ - Sketch `details` carries coverage counts; its compact text names the highest-value entry points. On an unfamiliar repo, fetch it once and reuse it instead of rediscovering entry points per call.
40
41
 
41
42
  ## CLI
42
43
 
@@ -44,4 +45,4 @@ The same engine runs headlessly as the `fovea` binary (repo root scan, plus JSON
44
45
 
45
46
  ## Settings
46
47
 
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).
48
+ Use `/fovea status` for loaded version and index coverage, `/fovea reset` for fresh state, `/fovea reload` after updates, and `/fovea settings` for configuration. Files live under `~/.pi/agent/fovea.json` or trusted `.pi/fovea.json`. `tools.replaceGrep` installs hybrid grep: native text semantics plus bare-query graph navigation.
@@ -49,7 +49,21 @@ export const hasAstGrep = (): boolean => {
49
49
 
50
50
  const CHUNK = 160;
51
51
 
52
- const run = (args: string[], cwd: string): string => {
52
+ // Extraction honesty ledger: a failed ast-grep invocation implicates every
53
+ // file in its chunk. build.ts drains this once per fact pass and folds it
54
+ // into the extraction report surfaced by tools, /fovea status, `fovea status`.
55
+ export interface ExtractionFailure {
56
+ op: "outline" | "outline-structured" | "run";
57
+ lang?: string;
58
+ files: string[];
59
+ }
60
+ const failures: ExtractionFailure[] = [];
61
+ const recordFailure = (op: ExtractionFailure["op"], files: string[], lang?: string): void => {
62
+ failures.push({ op, lang, files });
63
+ };
64
+ export const drainExtractionFailures = (): ExtractionFailure[] => failures.splice(0, failures.length);
65
+
66
+ const run = (args: string[], cwd: string): { ok: boolean; stdout: string } => {
53
67
  const res = spawnSync(binary(), args, {
54
68
  cwd,
55
69
  encoding: "utf8",
@@ -57,8 +71,14 @@ const run = (args: string[], cwd: string): string => {
57
71
  maxBuffer: 128 * 1024 * 1024,
58
72
  stdio: ["pipe", "pipe", "pipe"],
59
73
  });
60
- if (res.error || res.status !== 0) return "";
61
- return res.stdout ?? "";
74
+ if (res.error || res.signal) return { ok: false, stdout: "" };
75
+ if (res.status !== 0) {
76
+ // grep convention: `ast-grep run` exits 1 silently on zero matches, so a
77
+ // bare non-zero status is not a failure. Only a verbose one is.
78
+ if ((res.stderr ?? "").trim()) return { ok: false, stdout: "" };
79
+ return { ok: true, stdout: "" };
80
+ }
81
+ return { ok: true, stdout: res.stdout ?? "" };
62
82
  };
63
83
 
64
84
  export const langOf = (file: string): string | undefined => {
@@ -112,7 +132,10 @@ export interface OutlineFile {
112
132
  export const outlineStructured = (files: string[], lang: string, cwd: string): OutlineFile[] | undefined => {
113
133
  const out: OutlineFile[] = [];
114
134
  for (let i = 0; i < files.length; i += CHUNK) {
115
- const stdout = run(
135
+ // A subprocess failure here is NOT recorded: extractSymbols falls back to
136
+ // the text outline for old ast-grep versions, and that text run is what
137
+ // records a genuine failure (old versions must not read as failures).
138
+ const { stdout } = run(
116
139
  ["outline", "--json=compact", "--view=expanded", ...files.slice(i, i + CHUNK)],
117
140
  cwd,
118
141
  );
@@ -132,9 +155,14 @@ export const outlineStructured = (files: string[], lang: string, cwd: string): O
132
155
  export const outline = (files: string[], lang: string, cwd: string): string => {
133
156
  let out = "";
134
157
  for (let i = 0; i < files.length; i += CHUNK) {
135
- out += run(["outline", ...files.slice(i, i + CHUNK)], cwd);
158
+ const chunk = files.slice(i, i + CHUNK);
159
+ const { ok, stdout } = run(["outline", ...chunk], cwd);
160
+ if (!ok) {
161
+ recordFailure("outline", chunk, lang);
162
+ continue;
163
+ }
164
+ out += stdout;
136
165
  }
137
- void lang;
138
166
  return out;
139
167
  };
140
168
 
@@ -157,10 +185,15 @@ export const patternRun = (
157
185
  ): AgMatch[] => {
158
186
  const out: AgMatch[] = [];
159
187
  for (let i = 0; i < files.length; i += CHUNK) {
160
- const stdout = run(
161
- ["run", "--pattern", pattern, "--lang", lang, "--json=compact", ...files.slice(i, i + CHUNK)],
188
+ const chunk = files.slice(i, i + CHUNK);
189
+ const { ok, stdout } = run(
190
+ ["run", "--pattern", pattern, "--lang", lang, "--json=compact", ...chunk],
162
191
  cwd,
163
192
  );
193
+ if (!ok) {
194
+ recordFailure("run", chunk, lang);
195
+ continue;
196
+ }
164
197
  if (!stdout.trim()) continue;
165
198
  let parsed: RawMatch[];
166
199
  try {
@@ -15,11 +15,14 @@ const MIN_BASIN_SIZE = 4;
15
15
 
16
16
  // eligible marks nodes that may seed a basin (symbols, not files/anchors: a
17
17
  // file's contains-star has ~zero triangle density and yields useless seeds).
18
+ // include optionally constrains every member, for operation-specific views
19
+ // such as production-first sketching without changing the underlying graph.
18
20
  export const detectBasins = (
19
21
  adjacency: Map<number, Array<{ to: number; kind: string; w: number }>>,
20
22
  conductance: Float64Array,
21
23
  n: number,
22
24
  eligible?: (i: number) => boolean,
25
+ include?: (i: number) => boolean,
23
26
  ): Basin[] => {
24
27
  // Triangle density: fraction of a node's neighbors that are co-neighbors.
25
28
  // Cheap O(deg^2) sampling with degree cap — hubs are star points anyway.
@@ -60,12 +63,14 @@ export const detectBasins = (
60
63
  const order: number[] = [seed];
61
64
  let internal = 0;
62
65
  const boundary = new Map<number, number>();
63
- for (const e of adjacency.get(seed) ?? []) boundary.set(e.to, (boundary.get(e.to) ?? 0) + e.w);
66
+ for (const e of adjacency.get(seed) ?? []) {
67
+ if (!include || include(e.to)) boundary.set(e.to, (boundary.get(e.to) ?? 0) + e.w);
68
+ }
64
69
  while (order.length < MAX_BASIN_SIZE && boundary.size) {
65
70
  let best = -1;
66
71
  let bestRatio = -1;
67
72
  for (const [j, inW] of boundary) {
68
- if (claimed.has(j) || members.has(j)) continue;
73
+ if (claimed.has(j) || members.has(j) || (include && !include(j))) continue;
69
74
  const total = [...(adjacency.get(j) ?? [])].reduce((s, e) => s + e.w, 0);
70
75
  const ratio = total > 0 ? inW / total : 0;
71
76
  if (ratio > bestRatio) { bestRatio = ratio; best = j; }
@@ -79,7 +84,7 @@ export const detectBasins = (
79
84
  internal += boundary.get(best) ?? 0;
80
85
  boundary.delete(best);
81
86
  for (const e of adjacency.get(best) ?? []) {
82
- if (members.has(e.to)) continue;
87
+ if (members.has(e.to) || (include && !include(e.to))) continue;
83
88
  boundary.set(e.to, (boundary.get(e.to) ?? 0) + e.w);
84
89
  }
85
90
  const cut = [...boundary.values()].reduce((a, b2) => a + b2, 0);
package/src/core/build.ts CHANGED
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from
8
8
  import { tmpdir } from "node:os";
9
9
  import { basename, dirname, join as joinPath, posix } from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
- import { LANG_BY_EXT, isBinaryExt, isConfigFile, langOf } from "./astgrep.js";
11
+ import { LANG_BY_EXT, drainExtractionFailures, isBinaryExt, isConfigFile, langOf } from "./astgrep.js";
12
12
  import { extractCalls, extractImports, extractLiterals, extractSymbols, isTestFile } from "./extract.js";
13
13
  import { buildJoinIndex } from "./join.js";
14
14
  import { extractAnchors, extractFileRoutes, loadRepoRules } from "./anchors.js";
@@ -30,7 +30,16 @@ export interface FileFacts {
30
30
  sigs?: FileSigs;
31
31
  }
32
32
 
33
- const CACHE_VERSION = 7; // bump when extractor semantics change
33
+ const CACHE_VERSION = 8; // bump when extractor semantics change
34
+
35
+ // Honest coverage: what the extractor could NOT see. Tools and status render
36
+ // this so a thin graph never reads as a small repo (files dropped silently).
37
+ export interface ExtractionReport {
38
+ /** Files implicated in at least one failed ast-grep invocation (chunk-granular). */
39
+ failed: string[];
40
+ /** Files skipped because their content could not be read (permissions, race). */
41
+ unreadable: string[];
42
+ }
34
43
  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
44
  const MAX_FILES = 24000;
36
45
  // Generated dependency manifests are enormous and carry no first-class routes.
@@ -100,7 +109,7 @@ interface CacheFile { version: number; root: string; rulesSha: string; facts: Re
100
109
  export const cachePathFor = (root: string): string =>
101
110
  joinPath(tmpdir(), `pi-fovea-${createHash("sha1").update(root).digest("hex").slice(0, 16)}.json`);
102
111
 
103
- export const loadFacts = (root: string, files: string[]): Record<string, FileFacts> => {
112
+ export const loadFacts = (root: string, files: string[]): { facts: Record<string, FileFacts>; report: ExtractionReport } => {
104
113
  const cacheFile = cachePathFor(root);
105
114
  let cached: CacheFile | undefined;
106
115
  try {
@@ -115,15 +124,18 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
115
124
  let implicitRules: SynthesizedRule[] = [];
116
125
  let rulesSha = baseRulesSha;
117
126
  if (cached && (cached.version !== CACHE_VERSION || cached.root !== root)) cached = undefined;
127
+ drainExtractionFailures(); // isolate this pass; a previous load's stale ledger must not spill over
118
128
  const facts: Record<string, FileFacts> = {};
119
129
  const dirty: string[] = [];
130
+ const unreadable: string[] = [];
120
131
  for (const rel of files) {
121
132
  const sha1 = sha1Of(root, rel);
122
133
  const hit = cached?.facts[rel];
123
134
  if (hit && hit.sha1 === sha1) {
124
135
  facts[rel] = hit;
125
136
  } else if (sha1.startsWith("!")) {
126
- continue; // unreadable; skip
137
+ unreadable.push(rel); // content unreadable (permissions, race): count it, skip it
138
+ continue;
127
139
  } else {
128
140
  dirty.push(rel);
129
141
  facts[rel] = { sha1, symbols: [], imports: [], calls: [], literals: [], anchors: [] };
@@ -198,7 +210,10 @@ export const loadFacts = (root: string, files: string[]): Record<string, FileFac
198
210
  } catch {
199
211
  // Cache is an optimization; never fail the build over it.
200
212
  }
201
- return facts;
213
+ // Chunk-granular by construction: one failed invocation implicates its whole
214
+ // chunk, so dedupe across extraction stages (symbols + imports + calls + ...).
215
+ const failed = [...new Set(drainExtractionFailures().flatMap((failure) => failure.files))].sort();
216
+ return { facts, report: { failed, unreadable: unreadable.sort() } };
202
217
  };
203
218
 
204
219
  // --- resolution ---------------------------------------------------------------
@@ -11,18 +11,18 @@ import path from "node:path";
11
11
  interface FoveaSyncConfig {
12
12
  /** turn_end feedback loop on/off (the default-on, opt-out knob). */
13
13
  enabled: boolean;
14
- /** Token budget for the red (issues) message. */
14
+ /** Token budget for proactive model steering context. */
15
15
  budget: number;
16
16
  /** Also send a tiny model-visible ack on clean turns (default false: silent green). */
17
17
  ackClean: boolean;
18
- /** Number of newly-warm undisclosed files that justifies a red message on its own. */
18
+ /** Number of newly relevant files that justifies proactive steering on its own. */
19
19
  warmFileThreshold: number;
20
20
  }
21
21
 
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. */
25
+ /** Install hybrid grep: native text semantics plus bare-query Fovea navigation. */
26
26
  replaceGrep: boolean;
27
27
  }
28
28
 
@@ -270,8 +270,12 @@ const IMPORT_PATTERNS: Record<string, string[]> = {
270
270
  "import $$$I from '$M'",
271
271
  'import "$M"',
272
272
  "import '$M'",
273
- 'export $$$I from "$M"',
274
- "export $$$I from '$M'",
273
+ // ast-grep >= current rejects 'export $$$I from ...' parse-wide; the
274
+ // named and star forms are the valid decomposition of the same intent.
275
+ 'export { $$$I } from "$M"',
276
+ "export { $$$I } from '$M'",
277
+ 'export * from "$M"',
278
+ "export * from '$M'",
275
279
  'require("$M")',
276
280
  ],
277
281
  Go: ['import "$M"', 'import ( $$$S )'],