pi-fovea 0.13.2 → 0.13.4

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
@@ -185,8 +185,10 @@ update names the changed files, the route deltas, and the newly relevant files.
185
185
  It lists the causal channels behind each link: calls, imports, shared literals,
186
186
  tests, or co-change history. By default it also embeds the refreshed focus
187
187
  context of the top drift target (push). With `sync.pushFocus` off, the update
188
- ends with a suggested focus probe for the next call (pull). Clean turns stay
189
- silent. Enable `sync.ackClean` if you want an ack for those. Set `sync.mode` to
188
+ ends with a suggested focus probe for the next call (pull). Switching
189
+ branches re-baselines silently instead of steering: a `git checkout`
190
+ re-materializes the worktree, but the branch diff is not authored drift —
191
+ commits, pulls, and rebases still report. Clean turns stay silent. Enable `sync.ackClean` if you want an ack for those. Set `sync.mode` to
190
192
  `"hidden"` to keep red sync context working behind the scenes without rendering it
191
193
  in the transcript, or to `"disabled"` to turn continuous sync off.
192
194
 
@@ -208,7 +210,10 @@ FOVEA_TURN_SYNC=off pi
208
210
 
209
211
  Global settings live in `~/.pi/agent/fovea.json`. A trusted repo-level override
210
212
  sits in `<repo>/.pi/fovea.json`. These are the same two scopes pi-fabric uses
211
- with `fabric.json`.
213
+ with `fabric.json`. In `/fovea settings`, the configured external-editor key
214
+ (`Ctrl+G` by default) switches both the displayed values and save destination
215
+ between project overrides and global defaults. A project override can remain
216
+ effective while its global default is being edited.
212
217
 
213
218
  | Key | Default | Meaning |
214
219
  | --- | :-----: | ------- |
package/dist/cli.mjs CHANGED
@@ -306,6 +306,11 @@ var gitHead = async (root) => {
306
306
  const head = out?.trim();
307
307
  return head ? head : void 0;
308
308
  };
309
+ var gitReflogAction = async (root) => {
310
+ const out = await gitOut(root, ["reflog", "-1", "--format=%gs"]);
311
+ const line = out?.trim();
312
+ return line ? line : void 0;
313
+ };
309
314
  var gitPrefixes = /* @__PURE__ */ new Map();
310
315
  var gitPrefix = async (root) => {
311
316
  if (gitPrefixes.has(root)) {
@@ -2926,6 +2931,7 @@ var refreshState = async (state, hints = [], force = false) => {
2926
2931
  let files = state.files;
2927
2932
  const changed = [];
2928
2933
  const deleted = [];
2934
+ let checkout = false;
2929
2935
  const hinted = [.../* @__PURE__ */ new Set([...filterSupported(hints, routeRes), ...hints.filter((h) => store.facts.has(h))])];
2930
2936
  changed.push(...hinted);
2931
2937
  if (state.gitKind === "git") {
@@ -2933,6 +2939,9 @@ var refreshState = async (state, hints = [], force = false) => {
2933
2939
  if (probe) {
2934
2940
  const headMoved = probe.head !== state.head;
2935
2941
  state.head = probe.head;
2942
+ if (headMoved) {
2943
+ checkout = (await gitReflogAction(state.root))?.startsWith("checkout:") ?? false;
2944
+ }
2936
2945
  const needsList = probe.relist || probe.changes.some((c) => c.path.endsWith("/"));
2937
2946
  if (needsList) {
2938
2947
  files = await listFiles(state.root, routeRes);
@@ -2991,6 +3000,7 @@ var refreshState = async (state, hints = [], force = false) => {
2991
3000
  return state;
2992
3001
  }
2993
3002
  const fresh = await assembleState(state.root, files, store, report, state.gitKind, state.head, state.dirty);
3003
+ if (checkout) fresh.checkout = true;
2994
3004
  states.set(state.root, fresh);
2995
3005
  return fresh;
2996
3006
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
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",
@@ -45,6 +45,6 @@ The same engine runs headlessly as the `fovea` binary (repo root scan, plus JSON
45
45
 
46
46
  ## Settings
47
47
 
48
- Use `/fovea status` for loaded version and index coverage, `/fovea reset` for fresh state, `/fovea reload` after updates, and `/fovea settings` for configuration. Files live under `~/.pi/agent/fovea.json` or trusted `.pi/fovea.json`. `tools.grepMode` controls the grep integration: `"augment"` (default) keeps native grep semantics and appends a Fovea graph section to symbol-query results — including `pi.grep` calls inside fabric_exec — `"replace"` keeps the legacy bare-query takeover, `"off"` is native only.
48
+ Use `/fovea status` for loaded version and index coverage, `/fovea reset` for fresh state, `/fovea reload` after updates, and `/fovea settings` for configuration. Files live under `~/.pi/agent/fovea.json` or trusted `.pi/fovea.json`; the external-editor key (`Ctrl+G` by default) switches the displayed and saved layer between global defaults and project overrides. `tools.grepMode` controls the grep integration: `"augment"` (default) keeps native grep semantics and appends a Fovea graph section to symbol-query results — including `pi.grep` calls inside fabric_exec — `"replace"` keeps the legacy bare-query takeover, `"off"` is native only.
49
49
 
50
50
  Budget overflow is not a dead end: any `… more results collapsed or outside budget` footer names a tmp artifact (`/tmp/pi-fovea-<op>-<hash>.txt`) holding the FULL list — read or grep that file for the remainder. Reach for `fovea_dwell` when you want a wider neighborhood, not just more of the same list.
@@ -155,9 +155,12 @@ const readConfigFile = (file: string): Record<string, unknown> => {
155
155
  }
156
156
  };
157
157
 
158
- export const loadFoveaConfig = (scopes: FoveaConfigScopes): FoveaConfig => {
158
+ const resolveFoveaConfig = (
159
+ scopes: FoveaConfigScopes,
160
+ includeProject: boolean,
161
+ ): FoveaConfig => {
159
162
  let config = applyPartial(DEFAULT_FOVEA_CONFIG, readConfigFile(globalFoveaConfigPath(scopes.agentDir)));
160
- if (scopes.projectTrusted) {
163
+ if (includeProject) {
161
164
  config = applyPartial(config, readConfigFile(projectFoveaConfigPath(scopes.cwd)));
162
165
  }
163
166
  // Environment override mirrors pi-fabric's PI_* precedence over stored values.
@@ -166,6 +169,19 @@ export const loadFoveaConfig = (scopes: FoveaConfigScopes): FoveaConfig => {
166
169
  return config;
167
170
  };
168
171
 
172
+ export const loadFoveaConfigForScope = (
173
+ scopes: FoveaConfigScopes,
174
+ scope: FoveaConfigScope,
175
+ ): FoveaConfig => {
176
+ if (scope === "project" && !scopes.projectTrusted) {
177
+ throw new Error("Cannot load project Fovea configuration for an untrusted project");
178
+ }
179
+ return resolveFoveaConfig(scopes, scope === "project");
180
+ };
181
+
182
+ export const loadFoveaConfig = (scopes: FoveaConfigScopes): FoveaConfig =>
183
+ resolveFoveaConfig(scopes, scopes.projectTrusted);
184
+
169
185
  const mergeDeep = (existing: Record<string, unknown>, partial: Record<string, unknown>): Record<string, unknown> => {
170
186
  const out = { ...existing };
171
187
  for (const [key, value] of Object.entries(partial)) {
package/src/core/git.ts CHANGED
@@ -41,6 +41,18 @@ export const gitHead = async (root: string): Promise<string | undefined> => {
41
41
  return head ? head : undefined;
42
42
  };
43
43
 
44
+ /**
45
+ * Subject of the most recent HEAD reflog entry, e.g. "checkout: moving from
46
+ * main to feature". Tells branch switches (re-baseline quietly) apart from
47
+ * commits/pulls/rebases (foreign drift worth reporting). Undefined when the
48
+ * reflog is unavailable or disabled — callers fall back to the loud path.
49
+ */
50
+ export const gitReflogAction = async (root: string): Promise<string | undefined> => {
51
+ const out = await gitOut(root, ["reflog", "-1", "--format=%gs"]);
52
+ const line = out?.trim();
53
+ return line ? line : undefined;
54
+ };
55
+
44
56
  export interface WorktreeChange {
45
57
  /** X+Y status columns, e.g. " M", "??", "D ". */
46
58
  code: string;
package/src/core/ops.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  type FactStore,
20
20
  type FileFacts,
21
21
  } from "./build.js";
22
- import { gitProbe, prFiles, uncommittedFiles } from "./git.js";
22
+ import { gitProbe, gitReflogAction, prFiles, uncommittedFiles } from "./git.js";
23
23
  import { ROOT_CACHE_LIMIT, envInt, yieldToLoop } from "./asyncutil.js";
24
24
  import { loadRepoRules } from "./anchors.js";
25
25
  import { buildCsr, chebyshevVectors, chooseOrder, heatField, type Csr } from "./heat.js";
@@ -62,6 +62,12 @@ export interface RepoState {
62
62
  * porcelain-clean with unmoved HEAD would otherwise keep serving its dirty
63
63
  * facts until the next edit. */
64
64
  dirty: Set<string>;
65
+ /** Set when this generation materialized from a `git checkout` (HEAD moved
66
+ * with reflog action "checkout:…"): sync re-baselines silently instead of
67
+ * cascading over the branch diff. Lives exactly one generation — the next
68
+ * fact-moving refresh builds a fresh state without it, so the quiet path
69
+ * cannot hide authored drift. */
70
+ checkout?: boolean;
65
71
  /** Past joint-edit affinity (raw conductance + last joint commit). History
66
72
  * is NOT structure: impact re-seeds these partners at recency-decayed
67
73
  * strength whenever a change lands, so old co-work cools like any heat. */
@@ -194,6 +200,12 @@ const refreshState = async (state: RepoState, hints: string[] = [], force = fals
194
200
  let files = state.files;
195
201
  const changed: string[] = [];
196
202
  const deleted: string[] = [];
203
+ // A checkout re-materializes the worktree from another ref; flag the
204
+ // rebuilt generation so sync re-baselines quietly. Only a HEAD move whose
205
+ // latest reflog action is "checkout:…" qualifies — pulls/rebases merge
206
+ // foreign work and keep the loud drift path, and reflog-less repos stay
207
+ // conservative (undefined -> false).
208
+ let checkout = false;
197
209
  const hinted = [...new Set([...filterSupported(hints, routeRes), ...hints.filter((h) => store.facts.has(h))])];
198
210
  changed.push(...hinted);
199
211
 
@@ -202,6 +214,9 @@ const refreshState = async (state: RepoState, hints: string[] = [], force = fals
202
214
  if (probe) {
203
215
  const headMoved = probe.head !== state.head;
204
216
  state.head = probe.head;
217
+ if (headMoved) {
218
+ checkout = (await gitReflogAction(state.root))?.startsWith("checkout:") ?? false;
219
+ }
205
220
  // Untracked directories appear collapsed ("dir/") in porcelain; adds
206
221
  // inside them only surface through a relist. Relist moments are rare.
207
222
  const needsList = probe.relist || probe.changes.some((c) => c.path.endsWith("/"));
@@ -274,6 +289,7 @@ const refreshState = async (state: RepoState, hints: string[] = [], force = fals
274
289
  return state;
275
290
  }
276
291
  const fresh = await assembleState(state.root, files, store, report, state.gitKind, state.head, state.dirty);
292
+ if (checkout) fresh.checkout = true;
277
293
  states.set(state.root, fresh);
278
294
  return fresh;
279
295
  };
package/src/core/sync.ts CHANGED
@@ -163,6 +163,9 @@ export const warmSync = async (root: string, params: WarmParams, state?: RepoSta
163
163
  const cur = state ?? (await ensureState(root, { hints: params.files ?? [], force: false }));
164
164
  const prev = getBaseline(root);
165
165
  if (!prev || prev.version === cur.version) return;
166
+ // A checkout generation re-baselines silently in sync: no cascade to
167
+ // prepare, and precomputing one over the branch diff would be waste.
168
+ if (cur.checkout) return;
166
169
  const files = semanticDrift(cur, prev);
167
170
  if (!files.length) return;
168
171
  const key = filesKey(files);
@@ -294,6 +297,19 @@ export const sync = async (
294
297
  };
295
298
  }
296
299
 
300
+ // Branch-switch re-baseline. A checkout moves HEAD and re-materializes the
301
+ // worktree without authored edits; cascading over the branch diff would
302
+ // fire a loud, useless steer on every switch, so the baseline just follows
303
+ // the ref (details.baseline keeps the hooks' ack-clean notify silent too).
304
+ // Heat memory belongs to the old ref's field and does not cross over.
305
+ if (state.checkout) {
306
+ setBaseline(root, await snapshot(state));
307
+ return {
308
+ structural: true, red: false, tokens: 0,
309
+ details: { version: state.version, checkout: true, baseline: "established", anchors: state.graph.anchors.length },
310
+ };
311
+ }
312
+
297
313
  // Version drifted: measure what moved. Implicit (tier-3 discovered) hubs
298
314
  // churn is reported but NEVER escalates alone — hypotheses with no first-class
299
315
  // backing don't get to wake the model with a red verdict.
@@ -3,7 +3,7 @@
3
3
  // SelectList submenus, dotted-id coercion) so both extensions feel like one
4
4
  // settings surface. Persistence: fovea.json under the pi agent dir, with a
5
5
  // project override beneath pi's configured project resource directory; the
6
- // external-editor keybinding toggles which scope the next change saves to.
6
+ // external-editor keybinding switches the configuration layer being viewed and saved.
7
7
 
8
8
  import {
9
9
  CONFIG_DIR_NAME,
@@ -29,6 +29,7 @@ import {
29
29
  import {
30
30
  buildPartialFromId,
31
31
  loadFoveaConfig,
32
+ loadFoveaConfigForScope,
32
33
  SYNC_MODES,
33
34
  saveFoveaConfig,
34
35
  type FoveaConfig,
@@ -133,15 +134,20 @@ export interface FoveaSettingsComponentOptions {
133
134
  initialSaveScope?: FoveaConfigScope;
134
135
  projectScopeAvailable?: boolean;
135
136
  onSaveScopeChange?: (scope: FoveaConfigScope) => void;
137
+ itemsForSaveScope?: (scope: FoveaConfigScope) => SettingItem[];
136
138
  }
137
139
 
138
140
  export class FoveaSettingsComponent extends Container {
139
- readonly settingsList: SettingsList;
141
+ settingsList: SettingsList;
140
142
  private readonly theme: Theme;
141
143
  private readonly keybindings: Pick<KeybindingsManager, "matches" | "getKeys"> | undefined;
142
144
  private readonly saveScopeText: Text;
145
+ private readonly settingsListContainer: Container;
143
146
  private readonly projectScopeAvailable: boolean;
147
+ private readonly onChange: (id: string, newValue: string) => void;
148
+ private readonly onCancel: () => void;
144
149
  private readonly onSaveScopeChange: (scope: FoveaConfigScope) => void;
150
+ private readonly itemsForSaveScope: ((scope: FoveaConfigScope) => SettingItem[]) | undefined;
145
151
  private saveScope: FoveaConfigScope;
146
152
 
147
153
  constructor(
@@ -158,16 +164,19 @@ export class FoveaSettingsComponent extends Container {
158
164
  this.saveScope = options.initialSaveScope === "global" || !this.projectScopeAvailable
159
165
  ? "global"
160
166
  : "project";
167
+ this.onChange = onChange;
168
+ this.onCancel = onCancel;
161
169
  this.onSaveScopeChange = options.onSaveScopeChange ?? (() => {});
170
+ this.itemsForSaveScope = options.itemsForSaveScope;
162
171
  this.addChild(new DynamicBorder((text) => theme.fg("border", text)));
163
172
  this.saveScopeText = new Text("", 1, 0);
164
173
  this.updateSaveScopeText();
165
174
  this.addChild(this.saveScopeText);
166
175
  this.addChild(new Spacer(1));
167
- this.settingsList = new SettingsList(items, 10, settingsListTheme(theme), onChange, onCancel, {
168
- enableSearch: true,
169
- });
170
- this.addChild(this.settingsList);
176
+ this.settingsListContainer = new Container();
177
+ this.settingsList = this.createSettingsList(items);
178
+ this.settingsListContainer.addChild(this.settingsList);
179
+ this.addChild(this.settingsListContainer);
171
180
  this.addChild(new DynamicBorder((text) => theme.fg("border", text)));
172
181
  }
173
182
 
@@ -177,6 +186,12 @@ export class FoveaSettingsComponent extends Container {
177
186
  this.saveScope = this.saveScope === "project" ? "global" : "project";
178
187
  this.updateSaveScopeText();
179
188
  this.onSaveScopeChange(this.saveScope);
189
+ const nextItems = this.itemsForSaveScope?.(this.saveScope);
190
+ if (nextItems) {
191
+ this.settingsListContainer.clear();
192
+ this.settingsList = this.createSettingsList(nextItems);
193
+ this.settingsListContainer.addChild(this.settingsList);
194
+ }
180
195
  return;
181
196
  }
182
197
  this.settingsList.handleInput(data);
@@ -187,17 +202,32 @@ export class FoveaSettingsComponent extends Container {
187
202
  this.updateSaveScopeText();
188
203
  }
189
204
 
205
+ private createSettingsList(items: SettingItem[]): SettingsList {
206
+ return new SettingsList(
207
+ items,
208
+ 10,
209
+ settingsListTheme(this.theme),
210
+ this.onChange,
211
+ this.onCancel,
212
+ { enableSearch: true },
213
+ );
214
+ }
215
+
190
216
  private updateSaveScopeText(): void {
191
217
  const destination = this.saveScope === "project"
192
- ? `Project (${CONFIG_DIR_NAME}/fovea.json)`
193
- : `Global (~/${CONFIG_DIR_NAME}/agent/fovea.json)`;
218
+ ? `Project overrides (${CONFIG_DIR_NAME}/fovea.json)`
219
+ : `Global defaults (~/${CONFIG_DIR_NAME}/agent/fovea.json)`;
194
220
  const keys = this.keybindings?.getKeys("app.editor.external") ?? [];
195
221
  const shortcut = keys.map(formatKey).join("/");
196
- const hint = this.projectScopeAvailable
197
- ? shortcut ? ` · ${shortcut} toggles save scope` : " · scope toggle key unavailable"
198
- : " · project scope unavailable for untrusted projects";
222
+ const hint = !this.projectScopeAvailable
223
+ ? " · project scope unavailable for untrusted projects"
224
+ : !shortcut
225
+ ? " · scope switch key unavailable"
226
+ : this.saveScope === "global"
227
+ ? ` · ${shortcut} switches scope · project overrides may remain active here`
228
+ : ` · ${shortcut} switches scope`;
199
229
  this.saveScopeText.setText(
200
- this.theme.fg("muted", "Save scope: ") +
230
+ this.theme.fg("muted", "Editing: ") +
201
231
  this.theme.fg("accent", destination) +
202
232
  this.theme.fg("dim", hint),
203
233
  );
@@ -299,7 +329,7 @@ export const openFoveaSettings = async (
299
329
  projectTrusted: context.isProjectTrusted(),
300
330
  };
301
331
  let saveScope: FoveaConfigScope = scopes.projectTrusted ? "project" : "global";
302
- let config = loadFoveaConfig(scopes);
332
+ let config = loadFoveaConfigForScope(scopes, saveScope);
303
333
  const initialGrepRegistration = config.tools.grepMode === "replace";
304
334
  let dirty = false;
305
335
 
@@ -313,16 +343,19 @@ export const openFoveaSettings = async (
313
343
  );
314
344
  return;
315
345
  }
316
- config = applyPartialLocal(config, id, value);
346
+ config = loadFoveaConfigForScope(scopes, saveScope);
317
347
  dirty = true;
318
348
  deps.onConfigApplied?.();
319
349
  };
320
350
 
321
351
  await context.ui.custom<void>((tui, theme, keybindings, done) => {
322
- const items = buildItems(theme, config);
352
+ const itemsForScope = (scope: FoveaConfigScope): SettingItem[] => {
353
+ config = loadFoveaConfigForScope(scopes, scope);
354
+ return buildItems(theme, config);
355
+ };
323
356
  return new FoveaSettingsComponent(
324
357
  theme,
325
- items,
358
+ itemsForScope(saveScope),
326
359
  (id, newValue) => apply(id, coerceValue(id, newValue, config)),
327
360
  () => done(),
328
361
  {
@@ -333,27 +366,16 @@ export const openFoveaSettings = async (
333
366
  saveScope = scope;
334
367
  tui.requestRender();
335
368
  },
369
+ itemsForSaveScope: itemsForScope,
336
370
  },
337
371
  );
338
372
  });
339
373
 
340
374
  if (dirty) context.ui.notify("Fovea settings saved.", "info");
341
- return { grepRegistrationChanged: (config.tools.grepMode === "replace") !== initialGrepRegistration };
342
- };
343
-
344
- // Local in-place merge so subsequent edits in the same overlay start from the
345
- // already-saved value (config is reloaded from disk on open).
346
- const applyPartialLocal = (config: FoveaConfig, id: string, value: unknown): FoveaConfig => {
347
- const next: FoveaConfig = {
348
- sync: { ...config.sync },
349
- tools: { ...config.tools },
375
+ const effectiveConfig = loadFoveaConfig(scopes);
376
+ return {
377
+ grepRegistrationChanged:
378
+ (effectiveConfig.tools.grepMode === "replace") !== initialGrepRegistration,
350
379
  };
351
- const segments = id.split(".");
352
- let target: Record<string, unknown> = next as unknown as Record<string, unknown>;
353
- for (let i = 0; i < segments.length - 1; i++) {
354
- target = target[segments[i]!] as Record<string, unknown>;
355
- }
356
- target[segments[segments.length - 1]!] = value;
357
- return next;
358
380
  };
359
381