pi-fovea 0.26.0 → 0.28.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 +85 -88
- package/dist/cli.mjs +25 -5
- package/docs/workspace.md +35 -0
- package/package.json +9 -4
- package/skills/pi-fovea/SKILL.md +21 -15
- package/src/core/asyncutil.ts +4 -2
- package/src/core/git.ts +5 -3
- package/src/core/ops.ts +8 -2
- package/src/core/roots.ts +189 -20
- package/src/core/session.ts +11 -3
- package/src/core/sync.ts +101 -19
- package/src/index.ts +149 -100
- package/src/workspace.ts +4 -0
package/README.md
CHANGED
|
@@ -150,101 +150,97 @@ npm i -g pi-fovea # or: bun add -g pi-fovea, bun add -g pi-fovea
|
|
|
150
150
|
|
|
151
151
|
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).
|
|
152
152
|
|
|
153
|
-
##
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
153
|
+
## Many projects, one conversation
|
|
154
|
+
|
|
155
|
+
Your Pi cwd can be a parent folder, a launcher, or unrelated to the work.
|
|
156
|
+
Successful native/Fabric `pi.*` reads, edits, writes, and searches automatically
|
|
157
|
+
select their containing project. Discovery walks bounded ancestors—not siblings
|
|
158
|
+
or the whole filesystem—and accepts only successful structured/literal access.
|
|
159
|
+
Startup and idle hooks do not index an otherwise-unselected launch directory.
|
|
160
|
+
|
|
161
|
+
**32 observed roots; two hot graphs.** The recency ring refreshes on access.
|
|
162
|
+
Project 33 retires the least recently used root instead of failing. Canonical
|
|
163
|
+
symlink aliases share identity; linked worktrees stay independent. Each root has
|
|
164
|
+
its own heat, attention, semantic baseline, and provenance. Retirement is an
|
|
165
|
+
explicit observation gap, never a clean verdict. Re-entry baselines anew.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
await pi.read({ path: "/projects/service/src/handler.ts", offset: 1, limit: 40 });
|
|
169
|
+
// Access alone enrolls the project; an unrooted call still answers about cwd.
|
|
170
|
+
await extensions.fovea_focus({ root: "/projects/service", query: "src/handler.ts", maxTokens: 1024 });
|
|
171
|
+
// An explicit root selects any exact directory, including umbrella scopes.
|
|
172
|
+
await extensions.fovea_focus({ root: "../other", query: "entry", maxTokens: 512 });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- Omitted analysis roots use the session cwd's own project—the nearest `.git` or
|
|
176
|
+
manifest, else the cwd itself—never the last selected project, so an unrooted
|
|
177
|
+
call cannot answer about a sibling repository. Contour keeps the recency ring
|
|
178
|
+
as a fallback for a coordinator cwd, where a review needs a Git worktree.
|
|
179
|
+
Explicit `root` resolves against **the tool context's cwd**, not process cwd
|
|
180
|
+
or the previous root. Parallel coordinators should supply it. Native paths
|
|
181
|
+
remain cwd-relative; neither Fovea nor Contour changes cwd. Results carry
|
|
182
|
+
`details.root`, `observedRoots`, `workspace` (capacity/retirements), and
|
|
183
|
+
`agentOrigin`.
|
|
184
|
+
- Enrollment happens after success, not before permission checks. Failed or
|
|
185
|
+
blocked calls enroll nothing. Automatic discovery excludes broad/system,
|
|
186
|
+
private, dependency, and generated locations. It does not parse arbitrary
|
|
187
|
+
programs or trust output text. Literal shell cwd forms are supported; opaque
|
|
188
|
+
programs and remote filesystems need explicit coordination.
|
|
189
|
+
- Indexing expands a successful path to a project scope. This is **not a sandbox
|
|
190
|
+
or a project-trust grant**. `.pi/fovea.json` is honored only for the exact
|
|
191
|
+
canonical trusted `ctx.cwd`; parent/sibling trust does not propagate. Existing
|
|
192
|
+
declarative `.fovea/rules.json` extraction rules are unchanged. Hosts with
|
|
193
|
+
finer-grained analysis permissions must enforce them separately.
|
|
194
|
+
- The first access is a new observation boundary. A first write may already be
|
|
195
|
+
included in it; Fovea does not invent its prior delta or authorship. Contour
|
|
196
|
+
still compares that project's patch with Git. Subsequent focus calls do not
|
|
197
|
+
consume pending drift. Focus/file-seeded impact establishes attention before
|
|
198
|
+
opaque shell edits; hintless changes in enrolled roots remain detectable.
|
|
199
|
+
- Native augment grep follows the physical owner of its actual search path,
|
|
200
|
+
never an unrelated active graph. No-path native grep still searches cwd.
|
|
201
|
+
Legacy replace-mode bare graph queries use the cwd project; native options and
|
|
202
|
+
fallbacks retain cwd semantics.
|
|
203
|
+
- Sync spends one shared context allowance on relevant messages, including root
|
|
204
|
+
labels and retirement notices—not an equal slice for every quiet root. Cold
|
|
205
|
+
unchanged Git roots do not rebuild graphs, and cold probes stay off the
|
|
206
|
+
blocking before-agent hook. Numerical paging retains logical focus/attention.
|
|
207
|
+
- Branch-local bounded root snapshots survive compaction, reload, resume, fork,
|
|
208
|
+
and tree navigation. Semantic baselines and trust are not restored. `/fovea
|
|
209
|
+
reset` clears the ring; `/fovea status` reports the ring, capacity, and the manual default. The CLI
|
|
210
|
+
stays stateless. Fovea and Contour exchange session-qualified target hints,
|
|
211
|
+
not heat, source content, trust, or mutation authorship.
|
|
212
|
+
|
|
213
|
+
### Bounded indexing
|
|
214
|
+
|
|
215
|
+
Explicit umbrella graphs retain progressive nested-repository/submodule
|
|
216
|
+
boundaries: projects join that graph as their contents are worked in, subject to
|
|
217
|
+
`FOVEA_MAX_FILES`. Automatic project selection does not create an umbrella graph
|
|
218
|
+
just because unrelated projects share a parent directory. Cold extraction keeps
|
|
219
|
+
streamed JSONL caches, 64-file batches, adaptive ast-grep chunk splitting, and
|
|
220
|
+
bounded I/O/process concurrency.
|
|
175
221
|
|
|
176
222
|
| Variable | Default | Meaning |
|
|
177
223
|
| --- | :---: | --- |
|
|
224
|
+
| `FOVEA_MAX_ROOTS` | `32` | observed recency ring, clamped to 1–32 |
|
|
225
|
+
| `FOVEA_CACHE_ROOTS` | `2` | hot graph/fact/vector cache residency, separate from observation |
|
|
178
226
|
| `FOVEA_MAX_FILES` | `8000` | maximum indexed files in one graph |
|
|
179
227
|
| `FOVEA_MAX_FILE_BYTES` | `1048576` | maximum bytes extracted from one source file |
|
|
180
|
-
| `
|
|
181
|
-
| `FOVEA_SPAWN_CONCURRENCY` | `3` | concurrent ast-grep/git child processes (ast-grep parallelizes parsing inside each process; values above ~4 rarely help) |
|
|
182
|
-
| `FOVEA_MEMORY_HALF_LIFE_HOURS` | `48` | wall-clock half-life of the per-node sync memory (charged cascade warmth) |
|
|
228
|
+
| `FOVEA_SPAWN_CONCURRENCY` | `3` | concurrent ast-grep/Git subprocesses |
|
|
183
229
|
| `FOVEA_IO_CONCURRENCY` | `32` | concurrent file stat/read operations |
|
|
184
|
-
| `
|
|
230
|
+
| `FOVEA_MEMORY_HALF_LIFE_HOURS` | `48` | wall-clock half-life of charged cascade memory |
|
|
231
|
+
| `FOVEA_GIT_UNTRACKED_CACHE` | enabled | set `0` to disable Git's native untracked-directory cache |
|
|
185
232
|
| `FOVEA_MAX_SUBMODULE_DEPTH` | `4` | recursion cap for nested submodules |
|
|
186
233
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
## Explicit project/worktree continuity (Rakazo)
|
|
194
|
-
|
|
195
|
-
The extension's existing four graph tools are the headless binding API; no new
|
|
196
|
-
host event or trust flag is required:
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
await extensions.fovea_focus({ root: "../project-b", query: "src/handler.ts", maxTokens: 1024 });
|
|
200
|
-
// Baseline is ready before this resolves (unless target sync is disabled).
|
|
201
|
-
// Native tools still use the host cwd: use absolute paths or cwd-relative paths.
|
|
202
|
-
await pi.edit({ path: "../project-b/src/handler.ts", old: "before", new: "after" });
|
|
203
|
-
await extensions.fovea_dwell({ maxTokens: 512 }); // last bound root
|
|
204
|
-
```
|
|
234
|
+
Root-discovery metadata is coalesced and TTL-cached; it never certifies source
|
|
235
|
+
freshness. Git status/content hashes and bounded manifest/boundary sweeps remain
|
|
236
|
+
the drift oracle, including dirty-to-clean reverts. Local Git analysis disables
|
|
237
|
+
fsmonitor and remote/lazy fetching. Native untracked caching can still update
|
|
238
|
+
Git **index metadata**, not Git configuration; opt out with
|
|
239
|
+
`FOVEA_GIT_UNTRACKED_CACHE=0` or `GIT_OPTIONAL_LOCKS=0`.
|
|
205
240
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
scopes, not automatically promoted to Git toplevels.
|
|
210
|
-
- Cwd is the startup/fallback observation target. After a graph call binds a root,
|
|
211
|
-
hooks inspect only the bound set. Bind each project you want observed; an
|
|
212
|
-
alternate binding does not keep an otherwise-unselected umbrella cwd active.
|
|
213
|
-
Calls without `root` use the last binding. Parallel coordinators should always
|
|
214
|
-
supply `root`; enrollment is serialized in invocation order. Results include
|
|
215
|
-
canonical `details.root` and sorted `details.observedRoots`.
|
|
216
|
-
- A first binding establishes that target's semantic baseline before edits can
|
|
217
|
-
follow. Subsequent focus calls do not consume pending drift. Before-prompt and
|
|
218
|
-
post-turn sync compare every bound target, including hintless shell/Fabric/editor
|
|
219
|
-
changes. Attention remains target-local: focus a file/symbol or use
|
|
220
|
-
`fovea_impact({ root, files: ["src/file.ts"], includeUncommitted: false })` before
|
|
221
|
-
a headless mutation to enter its scope. Ordinary path events never enroll a new
|
|
222
|
-
root; they route to the most specific enrolled physical owner.
|
|
223
|
-
- Native read/edit/write/search paths **do not change cwd**. Augment-mode grep
|
|
224
|
-
attaches only the graph of the enrolled owner of its actual cwd-relative or
|
|
225
|
-
absolute search path. No-path native grep still searches cwd; it never appends
|
|
226
|
-
an unrelated alternate graph. In legacy replace mode, bare graph queries and
|
|
227
|
-
their miss fallback use the bound root; explicit native options retain cwd
|
|
228
|
-
semantics. Each target's grep mode is checked independently.
|
|
229
|
-
- Binding is an explicit request to index a directory, **not a sandbox or a trust
|
|
230
|
-
grant**. Rakazo must authorize tool arguments itself. `.pi/fovea.json` is loaded
|
|
231
|
-
only when that exact canonical target equals `ctx.cwd` and the context is
|
|
232
|
-
trusted. Parent/sibling trust never authorizes it; alternate targets use global
|
|
233
|
-
defaults. To honor a target's project config, run it in its own trusted Pi
|
|
234
|
-
context. Config cache keys include trust and agent directory. Existing
|
|
235
|
-
target-local declarative `.fovea/rules.json` extraction rules are unchanged.
|
|
236
|
-
- All roots share one per-hook `sync.budget` allowance from the session cwd's
|
|
237
|
-
effective config, divided evenly and capped again by each target's budget;
|
|
238
|
-
root labels count toward that allowance. Hidden targets remain hidden (a mixed
|
|
239
|
-
pre-prompt aggregate is hidden). Graph calls keep their individual `maxTokens`.
|
|
240
|
-
Enrollment is capped by `FOVEA_MAX_ROOTS` (default 2); excess roots fail before
|
|
241
|
-
indexing rather than silently losing a baseline. Set this before startup for
|
|
242
|
-
larger coordinated runs.
|
|
243
|
-
- `/fovea status` reports the bound root and observed count. `/fovea reset`,
|
|
244
|
-
shutdown, new/resume/fork/reload clear bindings and conversation baselines;
|
|
245
|
-
reusable content facts remain cached. Rebind after session replacement.
|
|
246
|
-
The standalone CLI remains stateless; these continuity semantics belong to
|
|
247
|
-
the Pi extension lifecycle.
|
|
241
|
+
Oversized files and failed extractions remain visible coverage gaps in status
|
|
242
|
+
and tool details. See the [full workspace contract and shared API](docs/workspace.md)
|
|
243
|
+
for boundaries, persistence, scheduling, and remaining limitations.
|
|
248
244
|
|
|
249
245
|
## Turn sync
|
|
250
246
|
|
|
@@ -537,7 +533,8 @@ Exact contract topology: **Protocol Buffers (`.proto`) and GraphQL (`.graphql`,
|
|
|
537
533
|
|
|
538
534
|
```sh
|
|
539
535
|
bun install
|
|
540
|
-
bun run check
|
|
536
|
+
bun run check:fast # typecheck + tests your working tree affects
|
|
537
|
+
bun run test:smoke # curated scan-to-render floor, seconds
|
|
541
538
|
bun run bench # rate–distortion and refresh bench against ../pi-fabric
|
|
542
539
|
bun run bench tests/fixtures/mini # self-contained smoke run
|
|
543
540
|
```
|
|
@@ -560,7 +557,7 @@ disk-warm target builds are single samples; three-sample refresh p95s are only
|
|
|
560
557
|
smoke diagnostics. The outline gets no more tokens than Fovea actually used.
|
|
561
558
|
`fidelity@16k` measures disclosed node IDs against a finite larger Fovea response,
|
|
562
559
|
**not independently labeled relevance**. Timing results are informational, never
|
|
563
|
-
a flaky CI gate; deterministic equivalence tests run in `bun run
|
|
560
|
+
a flaky CI gate; deterministic equivalence tests run in the change-scoped selection (`bun run test:changed`).
|
|
564
561
|
The bench clears the target's disposable facts cache to measure cold loading,
|
|
565
562
|
but edits only temporary fixture copies.
|
|
566
563
|
|
package/dist/cli.mjs
CHANGED
|
@@ -21,7 +21,8 @@ var envInt = (name, dflt, min, max) => {
|
|
|
21
21
|
};
|
|
22
22
|
var SPAWN_CONCURRENCY = envInt("FOVEA_SPAWN_CONCURRENCY", 3, 1, 32);
|
|
23
23
|
var IO_CONCURRENCY = envInt("FOVEA_IO_CONCURRENCY", 32, 4, 512);
|
|
24
|
-
var
|
|
24
|
+
var OBSERVED_ROOT_LIMIT = envInt("FOVEA_MAX_ROOTS", 32, 1, 32);
|
|
25
|
+
var ROOT_CACHE_LIMIT = envInt("FOVEA_CACHE_ROOTS", 2, 1, 32);
|
|
25
26
|
var mapLimit = async (items, limit, fn) => {
|
|
26
27
|
const out = new Array(items.length);
|
|
27
28
|
let next = 0;
|
|
@@ -73,8 +74,10 @@ var gitOut = async (root, args, opts = {}) => spawnGate.run(
|
|
|
73
74
|
() => new Promise((resolve5) => {
|
|
74
75
|
execFile(
|
|
75
76
|
"git",
|
|
76
|
-
["-C", root, ...args],
|
|
77
|
+
["-c", "core.fsmonitor=false", "-C", root, ...args],
|
|
77
78
|
{
|
|
79
|
+
// Local analysis must not run repository hooks or lazily fetch objects.
|
|
80
|
+
env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_ALLOW_PROTOCOL: "", GIT_TERMINAL_PROMPT: "0" },
|
|
78
81
|
encoding: "utf8",
|
|
79
82
|
timeout: opts.timeout ?? GIT_TIMEOUT,
|
|
80
83
|
maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024
|
|
@@ -112,7 +115,7 @@ var gitPrefix = async (root) => {
|
|
|
112
115
|
const prefix = out.trim().replace(/\\/g, "/");
|
|
113
116
|
gitPrefixes.delete(root);
|
|
114
117
|
gitPrefixes.set(root, prefix);
|
|
115
|
-
while (gitPrefixes.size >
|
|
118
|
+
while (gitPrefixes.size > OBSERVED_ROOT_LIMIT) gitPrefixes.delete(gitPrefixes.keys().next().value);
|
|
116
119
|
return prefix;
|
|
117
120
|
};
|
|
118
121
|
var gitRelativePath = (path, prefix) => {
|
|
@@ -855,7 +858,7 @@ var getSession = (root) => {
|
|
|
855
858
|
tkKey: ""
|
|
856
859
|
};
|
|
857
860
|
sessions.set(root, s);
|
|
858
|
-
while (sessions.size >
|
|
861
|
+
while (sessions.size > OBSERVED_ROOT_LIMIT) {
|
|
859
862
|
const oldest = sessions.keys().next().value;
|
|
860
863
|
sessions.delete(oldest);
|
|
861
864
|
}
|
|
@@ -876,6 +879,10 @@ var syncScopeForPath = (root, input) => {
|
|
|
876
879
|
var observeSessionPaths = (root, paths) => {
|
|
877
880
|
const session = getSession(root);
|
|
878
881
|
for (const path of paths) {
|
|
882
|
+
if (path === ".") {
|
|
883
|
+
session.syncScopes.add(".");
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
879
886
|
const scope = syncScopeForPath(root, path);
|
|
880
887
|
if (scope) session.syncScopes.add(scope);
|
|
881
888
|
}
|
|
@@ -892,6 +899,13 @@ var clearSessionFocus = (session) => {
|
|
|
892
899
|
session.tk = [];
|
|
893
900
|
session.tkKey = "";
|
|
894
901
|
};
|
|
902
|
+
var retainSessionVectors = (root) => {
|
|
903
|
+
const others = [...sessions.values()].reverse().filter((s) => s.root !== root && s.tk.length);
|
|
904
|
+
for (const session of others.slice(Math.max(0, ROOT_CACHE_LIMIT - 1))) {
|
|
905
|
+
session.tk = [];
|
|
906
|
+
session.tkKey = "";
|
|
907
|
+
}
|
|
908
|
+
};
|
|
895
909
|
|
|
896
910
|
// src/core/basins.ts
|
|
897
911
|
var MAX_BASINS = 12;
|
|
@@ -5500,6 +5514,7 @@ var focus = async (root, query, budget, options = {}, ensured) => {
|
|
|
5500
5514
|
}
|
|
5501
5515
|
session.generation = state.generation;
|
|
5502
5516
|
if (session.tkKey !== key) {
|
|
5517
|
+
retainSessionVectors(root);
|
|
5503
5518
|
session.tk = chebyshevVectors(state.csr, seedVector(g.nodes.length, seeds), chooseOrder(session.t));
|
|
5504
5519
|
session.tkKey = key;
|
|
5505
5520
|
}
|
|
@@ -5544,7 +5559,7 @@ var dwell = async (root, factor, budget) => {
|
|
|
5544
5559
|
const g = state.graph;
|
|
5545
5560
|
const session = getSession(root);
|
|
5546
5561
|
const B = clampBudget(budget, 512);
|
|
5547
|
-
if (session.seeds.length && (session.generation !== state.generation || session.tk[0]?.length !== g.nodes.length)) {
|
|
5562
|
+
if (session.seeds.length && (session.generation !== state.generation || session.tk.length > 0 && session.tk[0]?.length !== g.nodes.length)) {
|
|
5548
5563
|
const previousGeneration = session.generation || "unknown";
|
|
5549
5564
|
clearSessionFocus(session);
|
|
5550
5565
|
const text = `fovea dwell: focus expired because the graph changed (${previousGeneration} \u2192 ${state.generation}). Call fovea_focus again; no stale vector was applied.`;
|
|
@@ -5558,6 +5573,11 @@ var dwell = async (root, factor, budget) => {
|
|
|
5558
5573
|
const text = "fovea dwell: no focus yet. Call fovea_focus with a symbol, feature id, route, or file first; dwell then deepens that field.";
|
|
5559
5574
|
return { text, tokens: tokenEstimate(text), details: { seeds: 0, ...extractionDetails(state) } };
|
|
5560
5575
|
}
|
|
5576
|
+
if (!session.tk.length) {
|
|
5577
|
+
retainSessionVectors(root);
|
|
5578
|
+
session.tk = chebyshevVectors(state.csr, seedVector(g.nodes.length, session.seeds), chooseOrder(session.t));
|
|
5579
|
+
session.tkKey = session.focusKey;
|
|
5580
|
+
}
|
|
5561
5581
|
const from = session.t;
|
|
5562
5582
|
const to = Math.min(64, from * Math.max(1.2, factor ?? 2));
|
|
5563
5583
|
session.t = to;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Roaming workspaces
|
|
2
|
+
|
|
3
|
+
The conversation is a coordinator. A project root is an independent observation domain, not the agent's process directory and not a common-ancestor graph joining unrelated repositories. Each domain keeps its own heat, attention, semantic baseline, and provenance. Contour's HEAD/index comparisons are also root-local; diffusion never crosses projects merely because they share a parent directory.
|
|
4
|
+
|
|
5
|
+
## Enrollment and boundaries
|
|
6
|
+
|
|
7
|
+
Successful `read`, `edit`, `write`, `grep`, `find`, and `ls` results provide structured paths. Literal shell cwd hints (`cwd`, `cd <literal> && …`, or `git -C <literal> …`) also qualify after success. Fabric's inner `pi.*` calls replay the native permission/result lifecycle and follow the same rule. We do not evaluate shell programs, inspect output text, expand variables/globs/substitutions, follow remote namespaces, or parse arbitrary `fabric_exec` source.
|
|
8
|
+
|
|
9
|
+
Discovery resolves physical paths and walks at most 64 ancestors. The nearest Git marker (directory or worktree `.git` file) wins; otherwise a recognized manifest, or the containing directory of a supported source file, is the conservative fallback. It does not enumerate sibling projects, run Git, or load project configuration. Lookups are coalesced and held in a 256-entry, one-second metadata cache. System-wide/home/private/dependency/generated paths are excluded from automatic discovery. This cache is **not** a source-freshness oracle.
|
|
10
|
+
|
|
11
|
+
Indexing expands the accessed file's scope to its containing project. Enrollment is not a sandbox, per-file ACL enforcement, or a project-configuration trust grant. A permission denial must happen before the underlying operation; failed or blocked results do not enroll anything. Hosts requiring finer-than-project analysis authorization must enforce it separately. Analysis Git commands disable fsmonitor, lazy fetching, and all transport protocols; missing local objects fail visibly instead of invoking repository-configured helpers or fetching. Explicit graph-tool roots remain available for intentional directory/umbrella scopes; Contour requires a Git worktree.
|
|
12
|
+
|
|
13
|
+
Native relative paths still resolve from Pi's tool-context cwd. Neither extension calls `process.chdir()`. An omitted manual root is the session cwd's own project — nearest Git marker or manifest, else the cwd itself — never the most recently observed root: Fovea stops at that cwd scope (an umbrella/coordinator directory is the subject, with nested repositories closed until access enrolls them), while Contour falls back to the recency ring because a review needs a Git worktree. Parallel callers should provide an explicit root. Canonical aliases share identity, but linked worktrees remain distinct. An explicitly selected narrow Fovea directory is not widened by later path activity it already owns.
|
|
14
|
+
|
|
15
|
+
## Bounded continuity
|
|
16
|
+
|
|
17
|
+
- `FOVEA_MAX_ROOTS`: 32 by default, clamped to 1–32. The recency ring refreshes on use; admitting root 33 retires the least recently used root rather than throwing.
|
|
18
|
+
- `FOVEA_CACHE_ROOTS`: 2 by default, independently controls heavy graph/fact/vector cache residency. Root-local semantic fingerprints and attention outlive numerical paging. A dwell reconstructs vectors for retained focus instead of silently forgetting it.
|
|
19
|
+
- `CONTOUR_MAX_ROOTS`: the same default/cap of 32. Immutable reports have separate bounded retention from the two hot graph generations.
|
|
20
|
+
|
|
21
|
+
Retirement clears root-local session/sync state and invalidates its lease. Old asynchronous work cannot publish a root result or steer after retirement/session replacement. Re-entry creates a new observation boundary. It does **not** certify the inactive interval. A first successful write can already be part of the new baseline: Fovea must not invent a prior delta or current-session authorship; Contour can still compare the patch with Git.
|
|
22
|
+
|
|
23
|
+
Branch-local custom entries retain only bounded root metadata. Compaction writes a fresh snapshot; reload/resume/fork/tree navigation restore the selected branch's roots, not stale semantic baselines, trust, or origin attribution. Reset explicitly clears the ring. Startup with no retained roots and idle coordinator hooks do not scan the launch directory.
|
|
24
|
+
|
|
25
|
+
Unchanged cold Git roots are checked without rebuilding their graphs; dirty-to-clean reverts still count. Cold probes are deferred off the blocking before-agent hook to the post-turn backstop. Non-Git manifests and explicit umbrella boundaries need periodic bounded sweeps. Shared sync context is spent on relevant messages, not divided by the number of unrelated quiet roots. Root labels and retirement notices count toward that budget.
|
|
26
|
+
|
|
27
|
+
Contour alternates recently accessed projects with a round-robin backstop, one root per scan. A 5-second scheduling tick is not a 5-second per-repository guarantee at 32 roots. Explicit checkpoints stop/abort speculative work, re-pin the requested repository's HEAD/index/worktree, and report root and agent origin separately. No discovery pass discloses findings or restarts an agent. `CONTOUR_BACKGROUND=0` leaves selection and explicit reviews available without polling.
|
|
28
|
+
|
|
29
|
+
## Lightweight shared API
|
|
30
|
+
|
|
31
|
+
`pi-fovea/workspace` exports `ExecutionRoots`, `ProjectDiscovery`, `canonicalPath`, `accessedPaths`, `WORKSPACE_ACCESS_EVENT`, `peerWorkspaceRoot`, `latestWorkspaceEntry`, `OBSERVED_ROOT_LIMIT`, and `envInt`. It imports no parser/graph/analysis engine, runs no Git, and does no filesystem work at module load.
|
|
32
|
+
|
|
33
|
+
Fovea and Contour publish version-1 hints on `WORKSPACE_ACCESS_EVENT` through Pi's local event bus: `{ version: 1, source: "fovea" | "contour", root, sessionId }`. Only matching nonempty session IDs and the other extension's source are accepted. Peer selections are not rebroadcast. This carries target metadata, not source content, heat, trust, or mutation authorship; processes and remote hosts need their own explicit coordination.
|
|
34
|
+
|
|
35
|
+
Executable coverage: `tests/roots.test.ts`, `tests/roaming.test.ts`, `tests/extension.test.ts`, and Contour's `tests/workspaces.test.ts` / `tests/startup.test.ts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fovea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -35,11 +35,13 @@
|
|
|
35
35
|
},
|
|
36
36
|
"exports": {
|
|
37
37
|
"./ops": "./src/core/ops.ts",
|
|
38
|
-
"./substrate": "./src/substrate.ts"
|
|
38
|
+
"./substrate": "./src/substrate.ts",
|
|
39
|
+
"./workspace": "./src/workspace.ts"
|
|
39
40
|
},
|
|
40
41
|
"files": [
|
|
41
42
|
"src",
|
|
42
43
|
"docs/substrate.md",
|
|
44
|
+
"docs/workspace.md",
|
|
43
45
|
"cli.ts",
|
|
44
46
|
"dist",
|
|
45
47
|
"skills",
|
|
@@ -55,7 +57,6 @@
|
|
|
55
57
|
},
|
|
56
58
|
"scripts": {
|
|
57
59
|
"typecheck": "tsc --noEmit",
|
|
58
|
-
"test": "vitest run",
|
|
59
60
|
"bench": "tsx scripts/bench.ts",
|
|
60
61
|
"corpus:coverage": "bun scripts/coverage-corpus.mjs",
|
|
61
62
|
"corpus:performance": "bun scripts/performance-corpus.mjs",
|
|
@@ -63,7 +64,11 @@
|
|
|
63
64
|
"prepack": "bun run build:cli",
|
|
64
65
|
"fovea": "tsx cli.ts",
|
|
65
66
|
"lint:dead": "knip",
|
|
66
|
-
"check": "bun run typecheck && bun run test
|
|
67
|
+
"check:fast": "bun run typecheck && bun run test:changed",
|
|
68
|
+
"test:changed": "node scripts/test-affected.mjs",
|
|
69
|
+
"test:affected": "node scripts/test-affected.mjs --base $PI_TEST_BASE",
|
|
70
|
+
"test:related": "vitest related",
|
|
71
|
+
"test:smoke": "vitest run tests/config.test.ts tests/settings.test.ts tests/substrate.test.ts tests/scan.test.ts tests/extract.test.ts tests/join.test.ts tests/heat.test.ts tests/nuclei.test.ts tests/render.test.ts"
|
|
67
72
|
},
|
|
68
73
|
"devDependencies": {
|
|
69
74
|
"@earendil-works/pi-coding-agent": "0.85.1",
|
package/skills/pi-fovea/SKILL.md
CHANGED
|
@@ -25,21 +25,27 @@ All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
|
|
|
25
25
|
|
|
26
26
|
## Multiple projects/worktrees
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
28
|
+
Successful native/Fabric `pi.*` path access automatically selects the containing
|
|
29
|
+
project, including disjoint repositories outside cwd. A neutral launch directory
|
|
30
|
+
is not implicitly indexed. Omitted analysis roots use the session cwd's own
|
|
31
|
+
project (nearest `.git` or manifest, else the cwd), never the most recent
|
|
32
|
+
selection; parallel callers should specify `root` explicitly. Explicit Fovea roots remain
|
|
33
|
+
exact directory scopes, resolved relative to `ctx.cwd`. Symlink aliases unify;
|
|
34
|
+
linked Git worktrees do not. Results expose root, origin, and workspace details.
|
|
35
|
+
|
|
36
|
+
Native tool paths still resolve from cwd: use absolute paths or `../project/file`.
|
|
37
|
+
Augment grep follows its actual search path, not an unrelated active binding.
|
|
38
|
+
Failed/blocked accesses, output text, arbitrary programs, and remote namespaces
|
|
39
|
+
do not enroll projects. Focus or file-seeded impact establishes attention before
|
|
40
|
+
opaque shell edits. Alternate projects do not inherit cwd project-config trust.
|
|
41
|
+
|
|
42
|
+
The default 32-root recency ring retires its least recently used root on overflow.
|
|
43
|
+
`FOVEA_MAX_ROOTS` can lower that cap; `FOVEA_CACHE_ROOTS` independently defaults to
|
|
44
|
+
two hot graphs. Retirement is an observation gap. First access/re-entry establishes
|
|
45
|
+
a new baseline; a first write cannot be retrospectively attributed or diffed.
|
|
46
|
+
Branch-local roots survive compaction/reload/resume, but old baselines do not.
|
|
47
|
+
Reset clears the ring. Contour shares session-qualified target hints when loaded.
|
|
48
|
+
See README's “Many projects, one conversation” and `docs/workspace.md`.
|
|
43
49
|
|
|
44
50
|
## Turn sync
|
|
45
51
|
|
package/src/core/asyncutil.ts
CHANGED
|
@@ -18,8 +18,10 @@ export const envInt = (name: string, dflt: number, min: number, max: number): nu
|
|
|
18
18
|
export const SPAWN_CONCURRENCY = envInt("FOVEA_SPAWN_CONCURRENCY", 3, 1, 32);
|
|
19
19
|
/** Max concurrent file reads/stats. */
|
|
20
20
|
export const IO_CONCURRENCY = envInt("FOVEA_IO_CONCURRENCY", 32, 4, 512);
|
|
21
|
-
/**
|
|
22
|
-
export const
|
|
21
|
+
/** Bounded observation ring; independent of heavyweight graph residency. */
|
|
22
|
+
export const OBSERVED_ROOT_LIMIT = envInt("FOVEA_MAX_ROOTS", 32, 1, 32);
|
|
23
|
+
/** Heavy graphs, fact stores, and numerical vectors remain a small hot cache. */
|
|
24
|
+
export const ROOT_CACHE_LIMIT = envInt("FOVEA_CACHE_ROOTS", 2, 1, 32);
|
|
23
25
|
|
|
24
26
|
/** Run fn over items with a global concurrency cap, preserving input order. */
|
|
25
27
|
export const mapLimit = async <T, R>(
|
package/src/core/git.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { posix, join } from "node:path";
|
|
7
7
|
import { stat } from "node:fs/promises";
|
|
8
|
-
import {
|
|
8
|
+
import { OBSERVED_ROOT_LIMIT, spawnGate } from "./asyncutil.js";
|
|
9
9
|
|
|
10
10
|
const GIT_TIMEOUT = 15_000;
|
|
11
11
|
|
|
@@ -20,8 +20,10 @@ export const gitOut = async (
|
|
|
20
20
|
new Promise<string | undefined>((resolve) => {
|
|
21
21
|
execFile(
|
|
22
22
|
"git",
|
|
23
|
-
["-C", root, ...args],
|
|
23
|
+
["-c", "core.fsmonitor=false", "-C", root, ...args],
|
|
24
24
|
{
|
|
25
|
+
// Local analysis must not run repository hooks or lazily fetch objects.
|
|
26
|
+
env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_ALLOW_PROTOCOL: "", GIT_TERMINAL_PROMPT: "0" },
|
|
25
27
|
encoding: "utf8",
|
|
26
28
|
timeout: opts.timeout ?? GIT_TIMEOUT,
|
|
27
29
|
maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
|
|
@@ -89,7 +91,7 @@ export const gitPrefix = async (root: string): Promise<string | undefined> => {
|
|
|
89
91
|
const prefix = out.trim().replace(/\\/g, "/");
|
|
90
92
|
gitPrefixes.delete(root);
|
|
91
93
|
gitPrefixes.set(root, prefix);
|
|
92
|
-
while (gitPrefixes.size >
|
|
94
|
+
while (gitPrefixes.size > OBSERVED_ROOT_LIMIT) gitPrefixes.delete(gitPrefixes.keys().next().value!);
|
|
93
95
|
return prefix;
|
|
94
96
|
};
|
|
95
97
|
|
package/src/core/ops.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { join, posix } from "node:path";
|
|
|
9
9
|
import { diffHunks, prFiles, uncommittedFiles } from "./git.js";
|
|
10
10
|
import { chebyshevVectors, chooseOrder, extendChebyshevVectors, forwardHeat, heatField } from "./heat.js";
|
|
11
11
|
import { formatNodeLocation, revealFoveated, revealGroups, tokenEstimate, type GroupLine, type RevealedNode } from "./render.js";
|
|
12
|
-
import { clearSessionFocus, FOCUS_T0, getSession, observeSessionPaths } from "./session.js";
|
|
12
|
+
import { clearSessionFocus, FOCUS_T0, getSession, observeSessionPaths, retainSessionVectors } from "./session.js";
|
|
13
13
|
import { detectBasins } from "./basins.js";
|
|
14
14
|
import { classifyLiteral, normalizeLiteral } from "./join.js";
|
|
15
15
|
import { isTestFile } from "./extract.js";
|
|
@@ -610,6 +610,7 @@ export const focus = async (
|
|
|
610
610
|
}
|
|
611
611
|
session.generation = state.generation;
|
|
612
612
|
if (session.tkKey !== key) {
|
|
613
|
+
retainSessionVectors(root);
|
|
613
614
|
session.tk = chebyshevVectors(state.csr, seedVector(g.nodes.length, seeds), chooseOrder(session.t));
|
|
614
615
|
session.tkKey = key;
|
|
615
616
|
}
|
|
@@ -657,7 +658,7 @@ export const dwell = async (root: string, factor?: number, budget?: number): Pro
|
|
|
657
658
|
const g = state.graph;
|
|
658
659
|
const session = getSession(root);
|
|
659
660
|
const B = clampBudget(budget, 512);
|
|
660
|
-
if (session.seeds.length && (session.generation !== state.generation || session.tk[0]?.length !== g.nodes.length)) {
|
|
661
|
+
if (session.seeds.length && (session.generation !== state.generation || (session.tk.length > 0 && session.tk[0]?.length !== g.nodes.length))) {
|
|
661
662
|
const previousGeneration = session.generation || "unknown";
|
|
662
663
|
clearSessionFocus(session);
|
|
663
664
|
const text = `fovea dwell: focus expired because the graph changed (${previousGeneration} → ${state.generation}). Call fovea_focus again; no stale vector was applied.`;
|
|
@@ -671,6 +672,11 @@ export const dwell = async (root: string, factor?: number, budget?: number): Pro
|
|
|
671
672
|
const text = "fovea dwell: no focus yet. Call fovea_focus with a symbol, feature id, route, or file first; dwell then deepens that field.";
|
|
672
673
|
return { text, tokens: tokenEstimate(text), details: { seeds: 0, ...extractionDetails(state) } };
|
|
673
674
|
}
|
|
675
|
+
if (!session.tk.length) {
|
|
676
|
+
retainSessionVectors(root);
|
|
677
|
+
session.tk = chebyshevVectors(state.csr, seedVector(g.nodes.length, session.seeds), chooseOrder(session.t));
|
|
678
|
+
session.tkKey = session.focusKey;
|
|
679
|
+
}
|
|
674
680
|
const from = session.t;
|
|
675
681
|
const to = Math.min(64, from * Math.max(1.2, factor ?? 2));
|
|
676
682
|
session.t = to;
|