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 +40 -20
- package/cli.ts +5 -1
- package/package.json +1 -1
- package/skills/pi-fovea/SKILL.md +9 -8
- package/src/core/astgrep.ts +41 -8
- package/src/core/basins.ts +8 -3
- package/src/core/build.ts +20 -5
- package/src/core/config.ts +3 -3
- package/src/core/extract.ts +6 -2
- package/src/core/ops.ts +263 -39
- package/src/core/render.ts +54 -11
- package/src/core/session.ts +21 -7
- package/src/core/sync.ts +101 -41
- package/src/index.ts +238 -60
- package/src/ui/settings.ts +10 -10
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
|
|
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? |
|
|
29
|
-
| `fovea_focus` | what is this? |
|
|
30
|
-
| `fovea_dwell` | what else? |
|
|
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
|
|
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 **
|
|
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
|
-
|
|
67
|
+
Runtime slash controls:
|
|
58
68
|
|
|
59
|
-
- `/fovea status` for graph
|
|
60
|
-
- `/fovea settings` for
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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` |
|
|
122
|
-
| `sync.budget` | `1024` | token cap for
|
|
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` |
|
|
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` |
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
package/skills/pi-fovea/SKILL.md
CHANGED
|
@@ -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`** —
|
|
13
|
-
2. **`fovea_focus` `<query>`** — point at a symbol name (close spellings work), route
|
|
14
|
-
3. **`fovea_dwell`** — optional second look. If
|
|
15
|
-
4. **`fovea_impact`** — blast radius. Seed with
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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.
|
package/src/core/astgrep.ts
CHANGED
|
@@ -49,7 +49,21 @@ export const hasAstGrep = (): boolean => {
|
|
|
49
49
|
|
|
50
50
|
const CHUNK = 160;
|
|
51
51
|
|
|
52
|
-
|
|
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.
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
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 {
|
package/src/core/basins.ts
CHANGED
|
@@ -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) ?? [])
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 ---------------------------------------------------------------
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
25
|
+
/** Install hybrid grep: native text semantics plus bare-query Fovea navigation. */
|
|
26
26
|
replaceGrep: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
package/src/core/extract.ts
CHANGED
|
@@ -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
|
|
274
|
-
|
|
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 )'],
|