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 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
- ## Large workspaces and startup
154
-
155
- Indexing runs in the background at `session_start`. Your first prompt never
156
- waits for ast-grep, hashing, or graph assembly. A cold sync hook reports the
157
- progress. Later calls reuse the same shared build.
158
-
159
- A non-Git umbrella directory treats each nested `.git` directory or worktree
160
- marker as a closed project boundary — until you work in it. The first edit
161
- hint (or observed drift) inside a nested clone enrolls it into the umbrella
162
- graph from then on: progressive disclosure, one project at a time, persisted
163
- with the fact cache so restarts restore your working set. The same rule covers
164
- submodules and embedded checkouts in Git roots: their contents join the graph
165
- as `<submodule>/<path>` the first time something inside changes — porcelain
166
- reports inner drift collapsed to the boundary, which enrolls it automatically —
167
- and a removed project un-enrolls without leaving orphan facts. Enrollment expands
168
- index coverage only: with the default session-local sync scope, a sibling project
169
- can join the umbrella graph and cache without steering conversations that never
170
- entered it. Every fovea_* tool still accepts a `root` for a full, immediate map
171
- of one project. `FOVEA_MAX_FILES` caps the merged listing either way.
172
- Cold runs stay bounded through streamed JSONL cache I/O, 64-file extraction
173
- batches, adaptive ast-grep chunk splitting, and a two-root resident LRU. The
174
- limits accept environment overrides:
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
- | `FOVEA_MAX_ROOTS` | `2` | observed execution roots and resident graph, fact, session, sync, and root-metadata caches |
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
- | `FOVEA_GIT_UNTRACKED_CACHE` | enabled | set to `0` to disable the native Git untracked-directory cache requested by freshness probes |
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
- Freshness probes still consult Git on every call; there is no new time-based shortcut. One porcelain-v2 status supplies both HEAD and changes, with a legacy fallback. At a verified worktree root, Fovea requests Git's native untracked-directory cache and omits the redundant dot pathspec that prevents its use. Subroots and Git environment overrides keep their explicit path scope. This can update Git index metadata, but does not change Git configuration files. Set `FOVEA_GIT_UNTRACKED_CACHE=0` to opt out; `GIT_OPTIONAL_LOCKS=0` can also prevent cache updates. Dirty files, new directories, ignore changes, and repository-boundary changes still go through freshness checks.
188
-
189
- Files over the size cap keep their place in the model's view of the repo.
190
- Failed extractions do the same. You find both in `/fovea status` and in tool
191
- details.
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
- - `root` resolves against **the tool context's cwd**, not process cwd or the last
207
- binding. Real paths unify symlink aliases; linked Git worktrees remain distinct
208
- even when they share HEAD and a common Git directory. Roots are exact directory
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 # typecheck + full vitest suite + knip
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 check`.
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 ROOT_CACHE_LIMIT = envInt("FOVEA_MAX_ROOTS", 2, 1, 32);
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 > ROOT_CACHE_LIMIT) gitPrefixes.delete(gitPrefixes.keys().next().value);
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 > ROOT_CACHE_LIMIT) {
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.26.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 && bun run lint:dead"
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",
@@ -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
- Use an explicit graph-tool `root` before editing an alternate authorized project.
29
- Roots resolve relative to `ctx.cwd`; symlink aliases share identity, linked Git
30
- worktrees do not. The first call establishes its sync baseline before returning.
31
- Graph results expose `details.root` and `details.observedRoots`; omitted roots use
32
- the last binding. Bind every target to observe; after binding, hooks stop using
33
- an unselected umbrella cwd. Parallel calls should specify roots explicitly.
34
-
35
- Native paths remain cwd-relative: use absolute paths or `../project/file` for
36
- edits and grep. Augment grep follows its actual search path's enrolled owner,
37
- not the active graph binding. Focus or file-seeded impact establishes attention
38
- for headless shell edits; path events alone never enroll siblings. Alternate
39
- roots do not inherit cwd project-config trust. Shared sync context and observed
40
- roots are bounded (`FOVEA_MAX_ROOTS`, default 2; excess enrollment errors).
41
- Reset/reload/session replacement clears bindings, not reusable extraction facts.
42
- See README's “Explicit project/worktree continuity (Rakazo)” for the full contract.
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
 
@@ -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
- /** Heavy per-root caches share one retention budget. */
22
- export const ROOT_CACHE_LIMIT = envInt("FOVEA_MAX_ROOTS", 2, 1, 32);
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 { ROOT_CACHE_LIMIT, spawnGate } from "./asyncutil.js";
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 > ROOT_CACHE_LIMIT) gitPrefixes.delete(gitPrefixes.keys().next().value!);
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;