pi-fovea
Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.
Package details
Install pi-fovea from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-fovea- Package
pi-fovea- Version
0.22.0- Published
- Sep 6, 2026
- Downloads
- 8,026/mo · 670/wk
- Author
- monotykamary
- License
- MIT
- Types
- extension, skill
- Size
- 660.8 KB
- Dependencies
- 0 dependencies · 3 peers
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
],
"skills": [
"./skills"
],
"image": "https://raw.githubusercontent.com/monotykamary/pi-fovea/main/media/cover.svg"
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
👁️ pi-fovea
A foveated repo-mapping extension for Pi
See the whole repo on every prompt, sharp where you work and cheap everywhere else.
pi-fovea gives the model a map of your repo on every prompt. The repo compiles once into a cross-language graph of code. Symbols, files, and route anchors share one network. Your question becomes an interest vector that diffuses through the graph as heat. The renderer caps the field inside a token budget. Near the question you get exact source locations and full signatures. One hop out you get typed relationships. Past that the repo collapses to a skeleton.
When a session starts, Fovea records a baseline of the repo. If files changed while Pi was idle, those changes enter context before the first model call. Fovea checks the repo again after each assistant turn. Detection uses file content hashes. An edit from a Pi tool, fabric_exec, bash, a subagent, or an editor looks the same to Fovea. Edits that touch only comments or formatting stay silent. A meaningful change arrives as a steer. When the agent is about to stop, Fovea starts the next turn itself.
Where fovea fits in shipping a feature
Shipping a feature in a large codebase costs time before the first edit. First you find where the feature lives. Then the change needs a map of everything it touches. Other branches keep landing while you work. Reviewers want the blast radius. In long-lived enterprise repos, these steps cost more time than the change itself. Fovea handles these steps. Each step becomes a cheap call against the code graph. Tests, review gates, CI, and rollout keep their own tools.
When not to reach for fovea
Skip Fovea on small repos. A repo of a few dozen files reads faster than it sketches. Fovea earns its cost when the working set outgrows the context window. Cross-language monorepos hit that wall early. A long-lived codebase you have never opened hits it too. Fovea narrows your reading to suggested windows. Open those windows yourself, and keep the project's format, lint, typecheck, and test commands in your loop. CI has the final say.
What the model gets
| Command | Ask | Answer |
|---|---|---|
fovea_sketch |
where is everything? | production-first silhouette plus explicit discovery/extraction coverage; test and fixture architecture stays collapsed |
fovea_focus |
what is this? | exact symbols/routes/protocol ids, evidenced relationships, path-gap reasons, suggested reads, scopes, and deterministic fresh views |
fovea_dwell |
what else? | widens the current focus, or expires safely when its graph generation changed |
fovea_impact |
what does this touch? | hunk-precise seeds, evidenced review paths, unmet co-change companions, and a persistent obligation checklist |
grep (default hybrid) |
graph or text? | bare identifiers, qualified symbols, repo paths, and routes use Fovea; search options and obvious regex retain native grep |
Focus normalizes camelCase and common inflections. An approximate name such as
switchServer can still resolve switchingServers. A query with no certain
match returns the nearest symbols plus their locations. Direct graph edges
carry labels such as caller, callee, route, shared literal, and co-change, plus
deterministic strategy / rule / source evidence. Every tool result includes
a coverage ledger; an explicit missing path says whether it is unsupported,
ignored, oversized, generated, unreadable, Git-listed but unavailable, beyond
the file cap, behind a closed nested-repository boundary, or absent. Symbols
that merely share a file stay collapsed.
The Hybrid grep toggle is on by default. grep({ pattern: "CreateUser" }),
grep({ pattern: "Controller.create" }), and route paths travel through the
graph. Calls that carry text-search options or obvious regexes go to Pi's
native grep. A graph miss falls back to native text. A graph error, such as a
broken ast-grep, falls back the same way and adds a one-line note marking the
result as native. Turn the toggle off to recover a purely native grep. A toggle
change reloads extensions, so Pi and pi-fabric capture the same behavior.
pi-fabric
Captured extension tools live under Fabric's extensions provider. Use the direct proxy when the action is known:
const result = await extensions.fovea_focus({ query: "CreateUserHandler", maxTokens: 512 });
return result.text;
For dynamic discovery, pass an object to tools.search and keep the returned namespaced ref:
const [action] = await tools.search({ query: "fovea_focus", limit: 5 });
if (!action) return "Fovea is not captured";
return tools.call({ ref: action.ref, args: { query: "CreateUserHandler" } });
The stable explicit ref is extensions.fovea_focus. The bare forms
fovea_focus and fovea.fovea_focus will miss.
Runtime slash controls:
/fovea statusfor loaded versions, graph coverage, and active modes/fovea settingsfor a TUI configuration overlay/fovea resetfor a fresh focus and sync baseline/fovea reloadto activate updated extension source
Install
Requires Node.js 20+. The install provisions ast-grep automatically through the @ast-grep/cli npm optional dependency; an ast-grep found on PATH takes precedence over the packaged copy, and FOVEA_AST_GREP=/path/to/sg overrides both.
pi install npm:pi-fovea
From GitHub:
pi install git:github.com/monotykamary/pi-fovea
From a local checkout:
bun install
pi install /absolute/path/to/pi-fovea
There is also a package for any agent shell or CI:
fovea sketch /path/to/repo 900
fovea focus /path/to/repo "/v1/messages" 800
fovea impact /path/to/repo --base main 1200
fovea rules /path/to/repo
fovea status /path/to/repo
Install the CLI globally — the published bin is a single self-contained bundle, so it runs on plain Node.js (no tsx, no node_modules):
npm i -g pi-fovea # or: bun add -g pi-fovea, bun add -g pi-fovea
From a checkout, bun run fovea runs the live source via tsx, and bun run build:cli rebuilds dist/cli.mjs (the prepack hook keeps the published bundle in sync).
Large workspaces and startup
Indexing runs in the background at session_start. Your first prompt never
waits for ast-grep, hashing, or graph assembly. A cold sync hook reports the
progress. Later calls reuse the same shared build.
A non-Git umbrella directory treats each nested .git directory or worktree
marker as a closed project boundary — until you work in it. The first edit
hint (or observed drift) inside a nested clone enrolls it into the umbrella
graph from then on: progressive disclosure, one project at a time, persisted
with the fact cache so restarts restore your working set. The same rule covers
submodules and embedded checkouts in Git roots: their contents join the graph
as <submodule>/<path> the first time something inside changes — porcelain
reports inner drift collapsed to the boundary, which enrolls it automatically —
and a removed project un-enrolls without leaving orphan facts. Enrollment expands
index coverage only: with the default session-local sync scope, a sibling project
can join the umbrella graph and cache without steering conversations that never
entered it. Every fovea_* tool still accepts a root for a full, immediate map
of one project. FOVEA_MAX_FILES caps the merged listing either way.
Cold runs stay bounded through streamed JSONL cache I/O, 64-file extraction
batches, adaptive ast-grep chunk splitting, and a two-root resident LRU. The
limits accept environment overrides:
| Variable | Default | Meaning |
|---|---|---|
FOVEA_MAX_FILES |
8000 |
maximum indexed files in one graph |
FOVEA_MAX_FILE_BYTES |
1048576 |
maximum bytes extracted from one source file |
FOVEA_MAX_ROOTS |
2 |
resident graph, fact, session, sync, and root-metadata caches |
FOVEA_SPAWN_CONCURRENCY |
3 |
concurrent ast-grep/git child processes (ast-grep parallelizes parsing inside each process; values above ~4 rarely help) |
FOVEA_MEMORY_HALF_LIFE_HOURS |
48 |
wall-clock half-life of the per-node sync memory (charged cascade warmth) |
FOVEA_IO_CONCURRENCY |
32 |
concurrent file stat/read operations |
FOVEA_MAX_SUBMODULE_DEPTH |
4 |
recursion cap for nested submodules |
Files over the size cap keep their place in the model's view of the repo.
Failed extractions do the same. You find both in /fovea status and in tool
details.
Turn sync
Continuous sync is enabled and visible by default. Before an agent starts, Fovea establishes its baseline or injects relevant drift ahead of the first model call. After every assistant turn it compares symbols, calls, imports, literals, and anchors again. Content hashes keep the unchanged fast path cheap. Edits that touch only comments or formatting raise no signal.
The default sync.scope is "session": path-bearing read/search/edit tools,
explicit focus/dwell results, and file-seeded impact calls add the top-level
logical directory (or exact root file) to that conversation's attention. Fovea
still indexes and baselines the whole root. Drift solely in sibling directories
is absorbed silently, so broad umbrella coverage does not become broad model
context. Set sync.scope to "repository" to restore root-wide steering.
A meaningful current, mixed, or unattributed change inside the attention scope
can still ship post-turn with deliverAs: "steer" and triggerTurn. A relevant
change attributed solely to another Fovea-enabled session is queued for the
next user prompt instead; it never restarts an idle agent. The compact update
names changed files, route deltas, and newly relevant files. Shell commands,
external editors, and agents without Fovea remain unattributed rather than
being guessed, while the path scope still keeps unrelated sibling sandboxes
quiet. Provenance journals accept either intercepted mutations or explicit trusted SHA-1 transitions, preserve supplied commit order, write one bounded replacement per event batch, expire after seven days, and live in
$TMPDIR; repository content remains the drift oracle. Updates list causal
channels such as calls, imports, shared literals, tests, or co-change history.
By default Fovea also embeds the refreshed focus context of the top drift target
(push). With sync.pushFocus off, the update ends with a suggested focus probe
for the next call (pull). Switching
branches re-baselines silently instead of steering: a git checkout
re-materializes the worktree, but the branch diff is not authored drift —
commits, pulls, and rebases still report. Clean turns stay silent. Enable sync.ackClean if you want an ack for those. Set sync.mode to
"hidden" to keep red sync context working behind the scenes without rendering it
in the transcript, or to "disabled" to turn continuous sync off.
Surprise is measured per graph node, not per file: a disclosed cascade
charges the symbols, literals, and anchors it warmed, and a later verdict
only counts mass exceeding that ledger. Re-editing the same spot re-seeds
the same charged nodes and stays silent — flip-flopped work cannot wake the
model twice, no matter how many times it flips within a session. A novel
hunk still fires, damped only by the charged nodes it overlaps. The ledger
cools by wall clock (48h half-life, FOVEA_MEMORY_HALF_LIFE_HOURS), so a
structurally re-heated neighborhood can earn a fresh verdict on a later
day. Anchor deltas follow the same evidence rule: a route add/remove
escalates only when its carrier file drifted, which makes transient
extraction artifacts quiet by construction.
Runtime controls:
/fovea status: loaded package and ast-grep versions, indexed coverage, anchor scopes, sync mode/attention scope, and grep mode/fovea reset: clear focus disclosure and depth, then establish a fresh sync baseline/fovea reload: hot-reload extensions and activate newly installed source; sync baselines (the verdict ledger) ride through on a global slot, so a reload no longer replays charged cascades as first disclosures/fovea settings: configure sync, budgets, and hybrid grep
Choose enabled, hidden, or disabled per repo or globally through settings. The environment override still turns sync off with:
FOVEA_TURN_SYNC=off pi
Salience and obligations
fovea_impact keeps two clocks on the same graph.
Heat finds what matters now. Seeds come from the diff. Hunk parsing maps each
change to the symbols that contain it. One unit of mass goes to each changed
file: 0.2 on the file node, 0.8 over the touched symbols in proportion to
sqrt(changed lines). Heat then spreads along static edges and along co-change
partners, and decays with wall-clock time. Edits that resist symbol-level
location fall back to the old file-node seed. New files, deletions, renames,
untracked paths, and oversized diffs all take that path.
Historical co-change is a decaying heat prior, never a permanent graph edge.
It counts up to 400 first-parent integration boundaries: merge net changes count
once, not again through their constituent commits. Explicit fixup!/squash!
followups join only uniquely resolved older subjects in that window. Time,
author, shared issue numbers, and unlabeled "forgot this" do not group work.
Boundaries are not proof of a semantic feature (release merges can mix work).
Aggregates above 24 tracked files emit no pairs but retain directional touch
counts; two distinct retained units are needed for a pair, three for expectations.
Raw history caching includes shallow-state identity; recency still applies at use.
Focus, sketch, and the structural diffusion operator remain unchanged.
The obligation ledger keeps the list. Every cascade merges its per-file
residual mass into a session epoch. Entries stay until evidence moves them. A
read marks inspected. An edit marks changed and raises the generation. A
verified run marks verified. A reset clears the epoch. Wall-clock time
touches nothing here, and disclosure removes nothing. A model that saw a file
still owes the work the ledger records.
Impact details carry three separate signals:
expectedButUnchanged: files with strong directional co-change history that stayed out of this diff. A Wilson lower bound drives the score, with lift, support, and recency gates. Treat it as the alarm for the serializer nobody edited.conservedMass: the same cascade under degree-corrected random-walk heat. Total mass stays fixed per connected component, so file masses compare across repos of different sizes. Sync gates stay on the older raw scale.obligationsandepoch: the strongest unresolved entries with their reasons and generations, plus epoch totals.
docs/heat-diffusion.md has the full mechanics.
Configuration
Global settings live in ~/.pi/agent/fovea.json. A trusted repo-level override
sits in <repo>/.pi/fovea.json. These are the same two scopes pi-fabric uses
with fabric.json. In /fovea settings, the configured external-editor key
(Ctrl+G by default) switches both the displayed values and save destination
between project overrides and global defaults. A project override can remain
effective while its global default is being edited.
| Key | Default | Meaning |
|---|---|---|
sync.mode |
"enabled" |
"enabled" shows model-visible sync messages, "hidden" keeps them model-visible but out of the transcript, and "disabled" turns sync off. Legacy sync.enabled booleans still parse. |
sync.scope |
"session" |
"session" steers only for top-level directories/root files this conversation entered while indexing the whole root; "repository" restores root-wide steering. |
sync.budget |
512 |
token cap for proactive steering context |
sync.ackClean |
false |
toast after clean structural turns |
sync.steerThreshold |
0.15 |
total surprise (channel-weighted heat above the session sync memory) that justifies proactive model steering |
sync.pushFocus |
true |
embed a budgeted focus preview of the top drift target in red syncs |
tools.defaultBudget |
512 |
fallback maxTokens for the fovea_* tools |
tools.grepMode |
"augment" |
"augment"\u0020keeps native grep and appends a Fovea graph section to symbol-query results (works with pi.grepinside fabric_exec too);"replace"keeps the legacy takeover where bare symbol queries navigate the graph instead of returning lines;"off"is native grep only. The legacy booleantools.replaceGrep still parses (true\u2192"replace", false\u2192"off") and loses to an explicit grepMode`. |
tools.grepAugmentBudget |
512 |
token cap for the appended graph section |
Budgets cap the rendered view, not the map: whenever sketch, focus, dwell, or
impact truncate results for budget, the full list spills to
$TMPDIR/pi-fovea-<op>-<hash>.txt and the footer names the path — read or grep
the file for the remainder. fovea_dwell remains the semantic widen.
How routes are found
Route anchors come from five port shapes. Together they cover most of the ecosystem:
| Port shape | Examples |
|---|---|
recv.verb("path", handlers…) |
express, koa, fastify, hono, gin, echo, chi, net/http |
| annotation + optional class prefix | NestJS @Controller + @Get, Flask and FastAPI decorators, Spring @RequestMapping + @GetMapping |
| verb embedded in the path | Go 1.22 mux.HandleFunc("GET /x", h) |
| verb as first string argument | chi r.Method("GET", path, h), aiohttp router.add_route("GET", path, h) |
| receiver-less DSL macros | Rails routes.rb, Phoenix router.ex, Django path(), Ktor routing { get("/x") {} } |
File-convention routers keep their paths in the file tree. Next.js App Router, Pages Router, SvelteKit, Nuxt, and Astro work this way. Fovea derives their anchors from file paths. The verb comes from exported handler names or filename suffixes.
Discovery mode
A repo may write routes in a shape fovea has never seen. The literal pass
harvests every call shape in it. Shapes with solid statistics get promoted into
implicit rules. A discovered anchor carries half the conductance of a declared
one and shows a △ sigil. Turn sync reports the churn. An unconfirmed
hypothesis cannot turn the verdict red. Once a known rule matches any site of a
hub, that hub upgrades to first-class.
fovea anchors <root> --discovered # the △ hypothesis hubs only
fovea rules <root> # promoted rules with evidence
fovea rules <root> --sigs # every path-touching signature, by precision
fovea rules <root> --adopt # persist promotions into .fovea/rules.json
.fovea/rules.json pins community or project rules in the repo:
{
"rules": [
{ "id": "fiber", "langs": ["Go"], "pattern": "$R.$M(\"$P\", $$H)", "methods": "^(get|post)$", "kind": "route" }
]
}
A rule may declare prefixPattern. A class-level prefix such as
@Controller('api/airports') then composes with per-method paths. A change to
the rules file invalidates the anchor extraction cache. The parsed facts above
the cache carry over.
Non-HTTP protocol topology
Fovea parses protocol grammar locally and deterministically; it does not run a
language server, descriptor compiler, broker, or model. GraphQL documents emit
named operations, root fields, schema types/type references, and fragments.
Protocol Buffer documents emit package-qualified services, methods, messages,
and request/response/field references. Exact generated gRPC method paths join
back to their method and service. Declared tRPC and oRPC procedures join
router members, split-file route constants, and client calls. Hono routes
register through verb methods and app.on, and hc() RPC client calls join
the server-declared hub. Literal publish/subscribe calls join on their channel
while preserving the producer/consumer rule in edge evidence.
Router object members anchor at every position: the first matching slot is
captured exactly and the remaining members are enumerated from the sibling
capture, each at its own line. tRPC receivers must root at the t builder or
a *Procedure factory, oRPC at the os builder, so trpc.post.list.query()
client proxies and oRPC oc.* contract-only shapes stay unlinked rather
than guessing a nested name.
Validated against 36 open-source repositories (hono, trpc, unnoq/orpc, documenso, cal.com, unkey, googleapis, protobuf, saleor, the published GitHub schema, mqtt.js, nats, ably, and more): Hono yields 1.2k route anchors plus 72 RPC-client joins, documenso 214 tRPC procedures, the proto corpus 9.7k anchors with zero keyword false-positives, and a 1.2 MB GitHub schema parses through the raised protocol byte cap.
Canonical ids can be focused directly:
RPC users.v1.Users/GetUser
RPC SERVICE users.v1.Users
RPC MESSAGE users.v1.User
GRAPHQL QUERY user
GRAPHQL TYPE User
TRPC loadUser
ORPC ping
CHANNEL users.changed
Blind spots are logged in src/core/anchors.ts. The remaining list covers
Rust proc-macro attributes (actix #[get("/x")]), constructor-assigned
prefixes (Flask Blueprint, FastAPI APIRouter(prefix=…), chi Mount, Express
Router mounts, Hono basePath/route sub-apps), scope and namespace
nesting in Phoenix, Rails, or Django include(), router members behind
spreads or beyond the first twelve positions, trpc.post.list.query() client
proxies, oRPC dynamic clients, Hono app.on with non-standard verbs or path
arrays and hc() chains deeper than one segment, GraphQL embedded inside
host-language strings, generated gRPC clients with no literal method path,
and other computed protocol names. Ambiguous strings remain unlinked rather
than guessed.
Coverage and completeness
File discovery records its source (git or bounded filesystem walk), recording
state (complete, partial, or truncated), supported/unsupported/excluded
counts, exact Git-listing cap omissions, closed nested repositories, unavailable
Git worktree entries, and unreadable traversal boundaries. Extraction separately
records partial failures, unreadable files, oversized files, and generated files.
Protocol documents (.proto, .graphql, .gql) use their own larger byte cap
(FOVEA_MAX_PROTO_FILE_BYTES, 8 MB default) because their exact readers never
reach ast-grep; a real-world schema larger than the code cap still parses.
Lists in tool details are bounded examples; the counters are not. A truncated
filesystem walk reports an unknown omission count instead of inventing one.
/fovea status and fovea status use this same ledger rather than comparing
unlike tracked and supported file counts.
How it works
The repo compiles to a typed graph. Your question becomes a source vector $s$ over the nodes. The field the model receives is the heat kernel at time $t$ over the Laplacian $L$:
$$ v(t) = e^{-tL} \cdot s \quad \text{with} \quad L = I - D^{-1/2} W D^{-1/2} $$
The four tools run one operator at four timescales. Sketch runs at $t=16$ with production hubs and anchors as seeds. Focus drops to $t=2$, seeded by your query. Dwell doubles $t$ inside the current focus. Impact takes changed files as its seed. A change of focus resets the sharp timescale and the disclosure scope.
A Chebyshev expansion evaluates the kernel. Rescale $M = L - I$ so the spectrum sits in $[-1,1]$. With $T_k$ as the Chebyshev polynomials and $I_k$ as the modified Bessel functions:
$$ e^{-tL} = e^{-t} \left[ I_0(t) T_0(M) + 2 \sum_{k\ge 1} (-1)^k I_k(t) T_k(M) \right] $$
The vectors $T_k(M) s$ stay cached in the session. A new timescale reuses those
vectors and pays only for fresh coefficients. They are bound to a hash of the
ordered graph generation (node identities/signatures and weighted, evidenced
edges). A refresh that changes that generation clears focus/disclosure state;
dwell fails closed and asks for a new focus rather than applying stale node
indices. The graph walk happens once per generation.
Discovery measures how often the argument at one slot of a call shape carries a route path. Shapes earn promotion past a Jeffreys-smoothed posterior:
$$ \hat{p} = \frac{\mathrm{pathN} + \frac{1}{2}}{\mathrm{n} + 1} \ge 0.55 \quad \text{with} \quad \mathrm{n} \ge 4 \text{ sites and} \ge 2 \text{ files} $$
Tests on eight cloned projects put corpus junk below $\hat{p} \approx 0.27$. Real route shapes land above $\hat{p} \approx 0.75$. The cutoff sits mid-cliff at any repo size.
The method draws on spectral-graph wavelets evaluated by shared Chebyshev recurrence. Progressive image coding contributes the budget-as-bitrate view over significance-ordered coefficients. Foveated rendering supplies the sharp center and the coarse rim. Aider's PageRank repo map is the fixed-timescale special case of this field. docs/heat-diffusion.md walks through conductance tiers, specificity bridges, hub gravity, and inferred regions.
Languages
Full symbol and call extraction: TypeScript, TSX, JavaScript, Python, Go, and Rust.
Outline-based symbols: Elixir, Ruby, C, C++, Java, Kotlin, Lua, PHP, Swift, Scala, Haskell, and Bash.
Config joins through literals: YAML, JSON, TOML, env, Markdown, and OpenAPI.
Exact contract topology: Protocol Buffers (.proto) and GraphQL (.graphql, .gql), joined to gRPC, tRPC, oRPC, Hono, and producer/consumer call sites.
Development
bun install
bun run check # typecheck + full vitest suite
bun run bench # rate–distortion bench against ../pi-fabric
pi loads the extension straight from src/ through jiti, so nothing needs
building. Per-repo JSONL caches live in $TMPDIR, guarded by per-file content
sha1 values and stat manifests. Cache I/O streams. Only dirty files re-run
ast-grep. Failed extractions keep fact-free hash markers that stay visible
across launches. Those files skip the retry on each start. Bump CACHE_VERSION
in src/core/build.ts whenever extractor semantics change.
Acknowledgments
Thanks to Alp, the original user whose request for a better LSP extension started this project.
MIT.