pi-fovea 0.7.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",
package/src/core/ops.ts CHANGED
@@ -840,8 +840,8 @@ export interface ImpactArgs {
840
840
  budget?: number;
841
841
  }
842
842
 
843
- export const impact = async (root: string, args: ImpactArgs): Promise<OpResult> => {
844
- const state = await ensureState(root);
843
+ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState): Promise<OpResult> => {
844
+ const state = ensured ?? (await ensureState(root));
845
845
  const g = state.graph;
846
846
  const B = clampBudget(args.budget, 2000);
847
847
  const files = new Set<string>(args.files ?? []);
package/src/core/sync.ts CHANGED
@@ -44,7 +44,80 @@ const setBaseline = (root: string, baseline: SyncBaseline): void => {
44
44
  while (baselines.size > ROOT_CACHE_LIMIT) baselines.delete(baselines.keys().next().value!);
45
45
  };
46
46
 
47
- export const resetSyncBaselines = (): void => baselines.clear();
47
+ export const resetSyncBaselines = (): void => {
48
+ baselines.clear();
49
+ warmCache.clear();
50
+ };
51
+
52
+ // Background warm. The blocking `sync` call on the user-perceived send path
53
+ // (before_agent_start / turn_end) recomputes extraction, graph assembly, the
54
+ // baseline fingerprint, and the impact cascade whenever the repo drifted.
55
+ // `warmSync` runs those heavyweight ingredients eagerly as soon as edits land
56
+ // (tool_execution_end), keyed by state version + changed-file set, so the same
57
+ // drift's sync call reuses them and stays verdict-only. Never advances the
58
+ // baseline, never reports, never throws; a drift without a warm (external
59
+ // edits between turns) falls back to the inline compute in `sync`.
60
+
61
+ interface WarmCompute {
62
+ /** State version this computation fingerprints. */
63
+ version: string;
64
+ /** Canonical key of the changed-file set this warm covers. */
65
+ filesKey: string;
66
+ /** Full next-baseline snapshot, identical to snapshot(state). */
67
+ snapshot: SyncBaseline;
68
+ /** impact() outputs for the changed set. */
69
+ warmedFiles: string[];
70
+ warmReasons: Record<string, string[]>;
71
+ }
72
+
73
+ const warmCache = new Map<string, WarmCompute>();
74
+
75
+ const filesKey = (files: readonly string[]): string => [...new Set(files)].sort().join("\n");
76
+
77
+ /** Files whose extracted facts moved since a baseline, in canonical order. */
78
+ const semanticDrift = (state: RepoState, prev: SyncBaseline): string[] => {
79
+ const changed = Object.keys(state.facts).filter(
80
+ (file) => prev.shas.get(file) !== state.facts[file]!.sha1,
81
+ );
82
+ return changed.filter(
83
+ (file) => prev.semantics.get(file) !== semanticFacts(state, file) && state.graph.byFile.has(file),
84
+ );
85
+ };
86
+
87
+ export interface WarmParams {
88
+ /** Optional drift hints, same role as sync hints; the probe stays the oracle. */
89
+ files?: string[];
90
+ /** Token budget for the impact cascade (mirrors sync.budget). */
91
+ budget: number;
92
+ }
93
+
94
+ export const warmSync = async (root: string, params: WarmParams, state?: RepoState): Promise<void> => {
95
+ try {
96
+ const cur = state ?? (await ensureState(root, { hints: params.files ?? [], force: false }));
97
+ const prev = getBaseline(root);
98
+ if (!prev || prev.version === cur.version) return;
99
+ const files = semanticDrift(cur, prev);
100
+ if (!files.length) return;
101
+ const key = filesKey(files);
102
+ const cached = warmCache.get(root);
103
+ if (cached && cached.version === cur.version && cached.filesKey === key) return;
104
+ const next = await snapshot(cur);
105
+ // The impact cascade runs against the same immutable state snapshot the
106
+ // fingerprint used, so the cached pair is consistent for cur.version.
107
+ const result = await impact(root, { files, includeUncommitted: false, budget: params.budget }, cur);
108
+ warmCache.set(root, {
109
+ version: cur.version,
110
+ filesKey: key,
111
+ snapshot: next,
112
+ warmedFiles: (result.details.warmedFiles as string[] | undefined) ?? [],
113
+ warmReasons: (result.details.warmedReasons as Record<string, string[]> | undefined) ?? {},
114
+ });
115
+ while (warmCache.size > ROOT_CACHE_LIMIT) warmCache.delete(warmCache.keys().next().value!);
116
+ } catch {
117
+ // Best-effort: a failed warm just means the next blocking sync computes
118
+ // inline (with its own error reporting), exactly as before.
119
+ }
120
+ };
48
121
 
49
122
  export interface SyncParams {
50
123
  /** Optional drift hints (e.g. files touched by pi's edit/write tools this
@@ -152,9 +225,7 @@ export const sync = async (
152
225
  const changed = Object.keys(state.facts).filter(
153
226
  (file) => prev.shas.get(file) !== state.facts[file]!.sha1,
154
227
  );
155
- const semanticChanged = changed.filter(
156
- (file) => prev.semantics.get(file) !== semanticFacts(state, file) && state.graph.byFile.has(file),
157
- );
228
+ const semanticChanged = semanticDrift(state, prev);
158
229
  const hinted = (params.files ?? []).filter((file) => semanticChanged.includes(file));
159
230
  const deleted = [...prev.shas.keys()].filter((file) => !(file in state.facts));
160
231
  const files = [...new Set([...semanticChanged, ...hinted])];
@@ -162,18 +233,37 @@ export const sync = async (
162
233
  let warmNow: Set<string> = new Set();
163
234
  let warmNew: string[] = [];
164
235
  let warmReasons: Record<string, string[]> = {};
236
+ let preparedBaseline: SyncBaseline | undefined;
165
237
  if (files.length) {
166
- const result = await impact(root, { files, includeUncommitted: false, budget: params.budget });
167
- const warmedFiles = (result.details.warmedFiles as string[] | undefined) ?? [];
168
- warmReasons = (result.details.warmedReasons as Record<string, string[]> | undefined) ?? {};
169
- warmNow = new Set(warmedFiles.filter((file) => !disclosedFiles.has(file) && !files.includes(file)));
238
+ // Edit-time `warmSync` may have precomputed the heavyweight ingredients —
239
+ // the next baseline fingerprint and the impact cascade — so this blocking
240
+ // hook only renders the verdict. Keyed by state version + changed set, a
241
+ // stale warm (more drift landed since) falls through to the inline compute.
242
+ const prepared = warmCache.get(root);
243
+ const preparedHit =
244
+ prepared !== undefined &&
245
+ prepared.version === state.version &&
246
+ prepared.filesKey === filesKey(files);
247
+ if (preparedHit) {
248
+ warmCache.delete(root);
249
+ preparedBaseline = prepared.snapshot;
250
+ warmReasons = prepared.warmReasons;
251
+ warmNow = new Set(prepared.warmedFiles.filter(
252
+ (file) => !disclosedFiles.has(file) && !files.includes(file),
253
+ ));
254
+ } else {
255
+ const result = await impact(root, { files, includeUncommitted: false, budget: params.budget });
256
+ const warmedFiles = (result.details.warmedFiles as string[] | undefined) ?? [];
257
+ warmReasons = (result.details.warmedReasons as Record<string, string[]> | undefined) ?? {};
258
+ warmNow = new Set(warmedFiles.filter((file) => !disclosedFiles.has(file) && !files.includes(file)));
259
+ }
170
260
  warmNew = prev.warmed === undefined
171
261
  ? [...warmNow]
172
262
  : [...warmNow].filter((file) => !prev.warmed!.has(file));
173
263
  }
174
264
 
175
265
  const pushed = new Set(prev.pushed ?? []);
176
- setBaseline(root, { ...(await snapshot(state)), warmed: warmNow, pushed });
266
+ setBaseline(root, { ...(preparedBaseline ?? (await snapshot(state))), warmed: warmNow, pushed });
177
267
 
178
268
  // Extraction failures leave fact gaps that look like anchor *removals* —
179
269
  // never escalate red on removals while degraded; the degraded note is
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ import { hasAstGrep } from "./core/astgrep.js";
11
11
  import { ROOT_CACHE_LIMIT } from "./core/asyncutil.js";
12
12
  import { dwell, ensureStateBackground, focus, impact, sketch } from "./core/ops.js";
13
13
  import { resetSessions } from "./core/session.js";
14
- import { resetSyncBaselines, sync } from "./core/sync.js";
14
+ import { resetSyncBaselines, sync, warmSync } from "./core/sync.js";
15
15
  import { openFoveaSettings } from "./ui/settings.js";
16
16
  import type { NodeKind } from "./core/types.js";
17
17
 
@@ -195,7 +195,28 @@ export default function fovea(pi: ExtensionAPI) {
195
195
  // conversation turns exit at zero cost through the version fast path.
196
196
  let turnFiles: string[] = [];
197
197
  let lastSyncError: string | undefined;
198
+ // Background warm pipeline: every edit schedules a debounced preparation of
199
+ // the next sync verdict, so the blocking sync on the send path (before_agent
200
+ // start / turn_end) reuses a precomputed fingerprint + impact cascade instead
201
+ // of re-extracting and re-diffusing while the UI waits.
202
+ const WARM_DEBOUNCE_MS = 250;
203
+ const warmTimers = new Map<string, ReturnType<typeof setTimeout>>();
204
+ const warmAfterEdit = (root: string, cfg: FoveaConfig): void => {
205
+ const rels = turnFiles
206
+ .map((p) => (p.startsWith(root + "/") ? p.slice(root.length + 1) : p))
207
+ .filter((p) => !p.startsWith("/"));
208
+ if (!rels.length || !cfg.sync.enabled) return;
209
+ const files = [...rels];
210
+ const existing = warmTimers.get(root);
211
+ if (existing) clearTimeout(existing);
212
+ warmTimers.set(root, setTimeout(() => {
213
+ warmTimers.delete(root);
214
+ void warmSync(root, { files, budget: cfg.sync.budget }).catch(() => {});
215
+ }, WARM_DEBOUNCE_MS));
216
+ };
198
217
  pi.on("session_shutdown", () => {
218
+ for (const timer of warmTimers.values()) clearTimeout(timer);
219
+ warmTimers.clear();
199
220
  lifecycleEpoch++;
200
221
  turnFiles = [];
201
222
  lastSyncError = undefined;
@@ -210,6 +231,12 @@ export default function fovea(pi: ExtensionAPI) {
210
231
  const args = event.args as { path?: unknown };
211
232
  if (typeof args.path === "string") turnFiles.push(args.path);
212
233
  });
234
+ // Warm once the file is actually on disk (tool_execution_start fires during
235
+ // preflight, before the write lands); the debounce also coalesces bursts.
236
+ pi.on("tool_execution_end", (event, ctx) => {
237
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
238
+ warmAfterEdit(ctx.cwd, configFor(ctx.cwd, ctx.isProjectTrusted()));
239
+ });
213
240
  pi.on("before_agent_start", async (_event, ctx) => {
214
241
  try {
215
242
  const cfg = configFor(ctx.cwd, ctx.isProjectTrusted());