pi-fovea 0.7.0 → 0.8.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
@@ -153,7 +153,7 @@ Global settings live in `~/.pi/agent/fovea.json`. A trusted repo-level override
153
153
  | `sync.enabled` | `true` | pre-agent and post-turn continuous sync |
154
154
  | `sync.budget` | `1024` | token cap for proactive steering context |
155
155
  | `sync.ackClean` | `false` | toast after clean structural turns |
156
- | `sync.warmFileThreshold` | `2` | newly relevant files that justify proactive model steering |
156
+ | `sync.steerThreshold` | `0.15` | total surprise (channel-weighted heat above the session sync memory) that justifies proactive model steering |
157
157
  | `sync.pushFocus` | `true` | embed a budgeted focus preview of the top drift target in red syncs |
158
158
  | `tools.defaultBudget` | `2000` | fallback maxTokens for the fovea_* tools |
159
159
  | `tools.replaceGrep` | `true` | install hybrid native-text / bare-query graph grep |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -15,8 +15,10 @@ interface FoveaSyncConfig {
15
15
  budget: number;
16
16
  /** Also send a tiny model-visible ack on clean turns (default false: silent green). */
17
17
  ackClean: boolean;
18
- /** Number of newly relevant files that justifies proactive steering on its own. */
19
- warmFileThreshold: number;
18
+ /** Total surprise — channel-adjusted cascade mass above the session heat
19
+ * memory — that justifies proactive model steering on warmth alone.
20
+ * Route and deletion signals always steer regardless. */
21
+ steerThreshold: number;
20
22
  /** Push (default): red syncs embed a budgeted focus preview of the top drift
21
23
  * target. Pull: the update ends in a Next: advisory the model may follow. */
22
24
  pushFocus: boolean;
@@ -39,7 +41,12 @@ export const DEFAULT_FOVEA_CONFIG: FoveaConfig = {
39
41
  enabled: true,
40
42
  budget: 1024,
41
43
  ackClean: false,
42
- warmFileThreshold: 2,
44
+ // Masses are heat units leaked outside the changed files (1 unit seeded
45
+ // per changed file), discounted by channel. Measured: a central fixture
46
+ // semantic edit (tests/fixtures/mini server/users.go) totals ≈0.077
47
+ // adjusted; a pair of weak shared-literal/co-change warm-ups ≈0.01–0.03;
48
+ // one strong call/import neighbor on a 350-node repo ≈0.19.
49
+ steerThreshold: 0.15,
43
50
  pushFocus: true,
44
51
  },
45
52
  tools: {
@@ -68,7 +75,7 @@ export const projectFoveaConfigPath = (cwd: string): string => path.join(cwd, CO
68
75
  const BOUNDS: Record<string, [number, number]> = {
69
76
  "sync.budget": [128, 8192],
70
77
  "tools.defaultBudget": [256, 16000],
71
- "sync.warmFileThreshold": [1, 16],
78
+ "sync.steerThreshold": [0.02, 8],
72
79
  };
73
80
 
74
81
  const clamp = (id: string, value: number): number => {
@@ -82,6 +89,12 @@ const boolValue = (value: unknown, fallback: boolean): boolean =>
82
89
  const intValue = (id: string, value: unknown, fallback: number): number =>
83
90
  clamp(id, typeof value === "number" && Number.isFinite(value) ? value : fallback);
84
91
 
92
+ const floatValue = (id: string, value: unknown, fallback: number): number => {
93
+ const n = typeof value === "number" && Number.isFinite(value) ? value : fallback;
94
+ const [lo, hi] = BOUNDS[id] ?? [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
95
+ return Math.min(hi, Math.max(lo, n));
96
+ };
97
+
85
98
  const applyPartial = (base: FoveaConfig, partial: unknown): FoveaConfig => {
86
99
  if (typeof partial !== "object" || partial === null || Array.isArray(partial)) return base;
87
100
  const src = partial as Record<string, unknown>;
@@ -92,7 +105,7 @@ const applyPartial = (base: FoveaConfig, partial: unknown): FoveaConfig => {
92
105
  enabled: boolValue(sync.enabled, base.sync.enabled),
93
106
  budget: intValue("sync.budget", sync.budget, base.sync.budget),
94
107
  ackClean: boolValue(sync.ackClean, base.sync.ackClean),
95
- warmFileThreshold: intValue("sync.warmFileThreshold", sync.warmFileThreshold, base.sync.warmFileThreshold),
108
+ steerThreshold: floatValue("sync.steerThreshold", sync.steerThreshold, base.sync.steerThreshold),
96
109
  pushFocus: boolValue(sync.pushFocus, base.sync.pushFocus),
97
110
  },
98
111
  tools: {
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 ?? []);
@@ -869,14 +869,21 @@ export const impact = async (root: string, args: ImpactArgs): Promise<OpResult>
869
869
  const exclude = new Set(seeds.map((i) => g.nodes[i]!.id));
870
870
  const seedFiles = new Set(seeds.map((i) => g.nodes[i]!.file));
871
871
 
872
- // Aggregate warmed mass per file (excluding the seeds themselves).
872
+ // Aggregate warmed mass per file (excluding the seeds themselves). Heat
873
+ // retained by the seed files (seedMass) is the scale-free normalizer turn
874
+ // sync uses, since raw mass shrinks with graph size.
873
875
  const fileAgg = new Map<string, number>();
874
876
  const fileTop = new Map<string, Array<[number, number]>>();
875
877
  const anchorHits: GroupLine[] = [];
878
+ let seedMass = 0;
876
879
  g.nodes.forEach((n, i) => {
877
- if (exclude.has(n.id) || seedFiles.has(n.file)) return;
880
+ if (exclude.has(n.id)) return;
878
881
  const v = field[i]!;
879
882
  if (v <= 1e-6) return;
883
+ if (seedFiles.has(n.file)) {
884
+ seedMass += v;
885
+ return;
886
+ }
880
887
  if (n.kind === "anchor") {
881
888
  anchorHits.push({ label: `⚑ ${n.name}`, mass: v, detail: `${n.file}:${n.line}` });
882
889
  return;
@@ -971,6 +978,15 @@ export const impact = async (root: string, args: ImpactArgs): Promise<OpResult>
971
978
  // and the strongest direct evidence channel without text re-parsing.
972
979
  warmedAnchors: anchorHits.map((h) => h.label.replace(/^⚑\s*/, "")),
973
980
  warmedFiles: fileEntries.map(([file]) => file),
981
+ // Heat retained by the seed files themselves — a graph-size-invariant
982
+ // normalizer for warmedMass (per-file warmth as a fraction of the heat
983
+ // the change site keeps).
984
+ seedMass: Number(seedMass.toFixed(6)),
985
+ // Per-file cascade mass (Σ node heat, seeds excluded). Turn-sync gates
986
+ // on these masses; the file list alone cannot rank a drizzle against a
987
+ // real cascade. Rounded so the warm-cache path and the inline path
988
+ // carry bit-identical payloads.
989
+ warmedMass: Object.fromEntries(fileEntries.map(([file, mass]) => [file, Number(mass.toFixed(6))])),
974
990
  warmedReasons: Object.fromEntries(fileEntries.map(([file]) => [
975
991
  file,
976
992
  [...(reasonByFile.get(file) ?? new Set(["graph path"]))],
@@ -190,7 +190,7 @@ export const revealFoveated = (
190
190
  if (h >= HOT_TIER || seedSet.has(i)) {
191
191
  lines.push(
192
192
  node.kind === "file"
193
- ? `▒ ${node.file}${context}`
193
+ ? `▪ ${node.file}${context}`
194
194
  : node.kind === "anchor"
195
195
  ? `⚑ ${node.sig}${context}`
196
196
  : `▲ ${formatNodeLocation(node)} ${node.sig}${context}`,
package/src/core/sync.ts CHANGED
@@ -23,8 +23,10 @@ interface SyncBaseline {
23
23
  shas: Map<string, string>;
24
24
  /** file -> extracted semantic facts, excluding the content hash. */
25
25
  semantics: Map<string, string>;
26
- /** Previously reported undisclosed warmth, for delta delivery. */
27
- warmed?: Set<string>;
26
+ /** file -> absorbed cascade mass (μ): the decayed union of all reported warmth. */
27
+ heat?: Map<string, number>;
28
+ /** Hysteresis latch: any red sync disarms warmth firing until total surprise drops back into the re-arm fraction. */
29
+ warmthArmed?: boolean;
28
30
  /** Drift targets already push-embedded in this baseline chain (embed-once). */
29
31
  pushed?: Set<string>;
30
32
  }
@@ -44,14 +46,114 @@ const setBaseline = (root: string, baseline: SyncBaseline): void => {
44
46
  while (baselines.size > ROOT_CACHE_LIMIT) baselines.delete(baselines.keys().next().value!);
45
47
  };
46
48
 
47
- export const resetSyncBaselines = (): void => baselines.clear();
49
+ export const resetSyncBaselines = (): void => {
50
+ baselines.clear();
51
+ warmCache.clear();
52
+ };
53
+
54
+ // Channel priors for the surprise gate. A cascade whose only evidence is a
55
+ // weak-prior channel (shared literal, co-change) must move proportionally
56
+ // more raw heat to steer. Reason labels mirror reasonFor() in ops.ts.
57
+ const CHANNEL_WEIGHT: Record<string, number> = {
58
+ "call dependency": 1,
59
+ "import dependency": 1,
60
+ "test dependency": 1,
61
+ "inheritance": 1,
62
+ "shared route": 1,
63
+ "co-change history": 0.5,
64
+ "shared literal": 0.35,
65
+ "graph path": 0.5,
66
+ };
67
+ // Unlabeled multi-hop warmth.
68
+ const CHANNEL_UNKNOWN = 0.5;
69
+ // Heat memory decay per structural sync (μ ← 0.7μ): repeat warmth re-fires
70
+ // only once it exceeds what the session was already told.
71
+ const HEAT_DECAY = 0.7;
72
+ // Hysteresis re-arm band, as a fraction of the steer threshold.
73
+ const REARM_FRACTION = 0.5;
74
+ const round4 = (x: number): number => Math.round(x * 1e4) / 1e4;
75
+
76
+ // Background warm. The blocking `sync` call on the user-perceived send path
77
+ // (before_agent_start / turn_end) recomputes extraction, graph assembly, the
78
+ // baseline fingerprint, and the impact cascade whenever the repo drifted.
79
+ // `warmSync` runs those heavyweight ingredients eagerly as soon as edits land
80
+ // (tool_execution_end), keyed by state version + changed-file set, so the same
81
+ // drift's sync call reuses them and stays verdict-only. Never advances the
82
+ // baseline, never reports, never throws; a drift without a warm (external
83
+ // edits between turns) falls back to the inline compute in `sync`.
84
+
85
+ interface WarmCompute {
86
+ /** State version this computation fingerprints. */
87
+ version: string;
88
+ /** Canonical key of the changed-file set this warm covers. */
89
+ filesKey: string;
90
+ /** Full next-baseline snapshot, identical to snapshot(state). */
91
+ snapshot: SyncBaseline;
92
+ /** impact() outputs for the changed set. */
93
+ warmedFiles: string[];
94
+ warmedMass: Record<string, number>;
95
+ warmReasons: Record<string, string[]>;
96
+ }
97
+
98
+ const warmCache = new Map<string, WarmCompute>();
99
+
100
+ const filesKey = (files: readonly string[]): string => [...new Set(files)].sort().join("\n");
101
+
102
+ /** Files whose extracted facts moved since a baseline, in canonical order. */
103
+ const semanticDrift = (state: RepoState, prev: SyncBaseline): string[] => {
104
+ const changed = Object.keys(state.facts).filter(
105
+ (file) => prev.shas.get(file) !== state.facts[file]!.sha1,
106
+ );
107
+ return changed.filter(
108
+ (file) => prev.semantics.get(file) !== semanticFacts(state, file) && state.graph.byFile.has(file),
109
+ );
110
+ };
111
+
112
+ export interface WarmParams {
113
+ /** Optional drift hints, same role as sync hints; the probe stays the oracle. */
114
+ files?: string[];
115
+ /** Token budget for the impact cascade (mirrors sync.budget). */
116
+ budget: number;
117
+ }
118
+
119
+ export const warmSync = async (root: string, params: WarmParams, state?: RepoState): Promise<void> => {
120
+ try {
121
+ const cur = state ?? (await ensureState(root, { hints: params.files ?? [], force: false }));
122
+ const prev = getBaseline(root);
123
+ if (!prev || prev.version === cur.version) return;
124
+ const files = semanticDrift(cur, prev);
125
+ if (!files.length) return;
126
+ const key = filesKey(files);
127
+ const cached = warmCache.get(root);
128
+ if (cached && cached.version === cur.version && cached.filesKey === key) return;
129
+ const next = await snapshot(cur);
130
+ // The impact cascade runs against the same immutable state snapshot the
131
+ // fingerprint used, so the cached pair is consistent for cur.version.
132
+ const result = await impact(root, { files, includeUncommitted: false, budget: params.budget }, cur);
133
+ warmCache.set(root, {
134
+ version: cur.version,
135
+ filesKey: key,
136
+ snapshot: next,
137
+ warmedFiles: (result.details.warmedFiles as string[] | undefined) ?? [],
138
+ warmedMass: (result.details.warmedMass as Record<string, number> | undefined) ?? {},
139
+ warmReasons: (result.details.warmedReasons as Record<string, string[]> | undefined) ?? {},
140
+ });
141
+ while (warmCache.size > ROOT_CACHE_LIMIT) warmCache.delete(warmCache.keys().next().value!);
142
+ } catch {
143
+ // Best-effort: a failed warm just means the next blocking sync computes
144
+ // inline (with its own error reporting), exactly as before.
145
+ }
146
+ };
48
147
 
49
148
  export interface SyncParams {
50
149
  /** Optional drift hints (e.g. files touched by pi's edit/write tools this
51
150
  * turn). Unioned into the warmth seeds; never the source of truth. */
52
151
  files?: string[];
53
152
  budget: number;
54
- warmFileThreshold: number;
153
+ /** Total surprise (channel-adjusted cascade mass above the session heat
154
+ * memory) that justifies proactive steering on warmth alone. Route and
155
+ * deletion signals bypass it. */
156
+ steerThreshold: number;
55
157
  /** Push vs pull (default push): embed the top file target's focus context. */
56
158
  pushFocus?: boolean;
57
159
  }
@@ -152,44 +254,106 @@ export const sync = async (
152
254
  const changed = Object.keys(state.facts).filter(
153
255
  (file) => prev.shas.get(file) !== state.facts[file]!.sha1,
154
256
  );
155
- const semanticChanged = changed.filter(
156
- (file) => prev.semantics.get(file) !== semanticFacts(state, file) && state.graph.byFile.has(file),
157
- );
257
+ const semanticChanged = semanticDrift(state, prev);
158
258
  const hinted = (params.files ?? []).filter((file) => semanticChanged.includes(file));
159
259
  const deleted = [...prev.shas.keys()].filter((file) => !(file in state.facts));
160
260
  const files = [...new Set([...semanticChanged, ...hinted])];
161
261
 
162
262
  let warmNow: Set<string> = new Set();
163
- let warmNew: string[] = [];
263
+ let warmMass: Record<string, number> = {};
164
264
  let warmReasons: Record<string, string[]> = {};
265
+ let preparedBaseline: SyncBaseline | undefined;
165
266
  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)));
170
- warmNew = prev.warmed === undefined
171
- ? [...warmNow]
172
- : [...warmNow].filter((file) => !prev.warmed!.has(file));
267
+ // Edit-time `warmSync` may have precomputed the heavyweight ingredients —
268
+ // the next baseline fingerprint and the impact cascade — so this blocking
269
+ // hook only renders the verdict. Keyed by state version + changed set, a
270
+ // stale warm (more drift landed since) falls through to the inline compute.
271
+ const prepared = warmCache.get(root);
272
+ const preparedHit =
273
+ prepared !== undefined &&
274
+ prepared.version === state.version &&
275
+ prepared.filesKey === filesKey(files);
276
+ if (preparedHit) {
277
+ warmCache.delete(root);
278
+ preparedBaseline = prepared.snapshot;
279
+ warmMass = prepared.warmedMass;
280
+ warmReasons = prepared.warmReasons;
281
+ warmNow = new Set(prepared.warmedFiles.filter(
282
+ (file) => !disclosedFiles.has(file) && !files.includes(file),
283
+ ));
284
+ } else {
285
+ const result = await impact(root, { files, includeUncommitted: false, budget: params.budget });
286
+ const warmedFiles = (result.details.warmedFiles as string[] | undefined) ?? [];
287
+ warmMass = (result.details.warmedMass as Record<string, number> | undefined) ?? {};
288
+ warmReasons = (result.details.warmedReasons as Record<string, string[]> | undefined) ?? {};
289
+ warmNow = new Set(warmedFiles.filter((file) => !disclosedFiles.has(file) && !files.includes(file)));
290
+ }
173
291
  }
174
292
 
175
- const pushed = new Set(prev.pushed ?? []);
176
- setBaseline(root, { ...(await snapshot(state)), warmed: warmNow, pushed });
293
+ // Surprise gate. Warmth earns a steer only to the extent its channel-adjusted
294
+ // mass exceeds μ, the session's heat memory: the decayed union of every
295
+ // cascade already disclosed. Repeat warmth around ongoing work contributes
296
+ // nothing; genuinely hotter re-warming fires again. Novelty is continuous,
297
+ // not a one-sync set difference.
298
+ const channelMass = (file: string): number => {
299
+ let prior = 0;
300
+ for (const reason of warmReasons[file] ?? []) prior = Math.max(prior, CHANNEL_WEIGHT[reason] ?? CHANNEL_UNKNOWN);
301
+ return (warmMass[file] ?? 0) * (prior || CHANNEL_UNKNOWN);
302
+ };
303
+ const memory = new Map<string, number>();
304
+ for (const [file, mass] of prev.heat ?? []) {
305
+ const decayed = mass * HEAT_DECAY;
306
+ if (decayed > 1e-6) memory.set(file, decayed);
307
+ }
308
+ const surprise = new Map<string, number>();
309
+ let surpriseTotal = 0;
310
+ for (const file of warmNow) {
311
+ const delta = channelMass(file) - (memory.get(file) ?? 0);
312
+ if (delta > 1e-9) {
313
+ surprise.set(file, delta);
314
+ surpriseTotal += delta;
315
+ }
316
+ }
177
317
 
318
+ const pushed = new Set(prev.pushed ?? []);
178
319
  // Extraction failures leave fact gaps that look like anchor *removals* —
179
320
  // never escalate red on removals while degraded; the degraded note is
180
321
  // already loud in details.
181
322
  const degraded = state.extraction.failed.length > 0;
182
- const red = (added.length - newlyImplicit.length) > 0 ||
323
+ const structuralRed = (added.length - newlyImplicit.length) > 0 ||
183
324
  (removed.length > 0 && !degraded) ||
184
- deleted.some((file) => !isTestScope(file)) ||
185
- warmNew.length >= Math.max(1, params.warmFileThreshold);
325
+ deleted.some((file) => !isTestScope(file));
326
+ const prevArmed = prev.warmthArmed !== false;
327
+ const warmthFire = prevArmed && surpriseTotal >= params.steerThreshold;
328
+ const red = structuralRed || warmthFire;
329
+ // Hysteresis: a red sync discloses its whole cascade, so the latch disarms
330
+ // until total surprise drops into the re-arm fraction of the threshold.
331
+ const warmthArmed = red ? false : prevArmed || surpriseTotal <= params.steerThreshold * REARM_FRACTION;
332
+ // Absorb on disclosure: every warmed file the message covers charges the
333
+ // memory at its current adjusted mass, displayed or not.
334
+ if (red) {
335
+ for (const file of warmNow) {
336
+ const adjusted = channelMass(file);
337
+ if (adjusted > (memory.get(file) ?? 0)) memory.set(file, adjusted);
338
+ }
339
+ }
340
+ const orderedWarm = [...surprise.entries()]
341
+ .sort((a, b) => b[1] - a[1] || Number(isTestScope(a[0])) - Number(isTestScope(b[0])) || a[0].localeCompare(b[0]))
342
+ .map(([file]) => file);
343
+ setBaseline(root, {
344
+ ...(preparedBaseline ?? (await snapshot(state))),
345
+ heat: memory.size ? memory : undefined,
346
+ warmthArmed,
347
+ pushed,
348
+ });
186
349
  if (!red) {
187
350
  return {
188
351
  structural: true, red: false, tokens: 0,
189
352
  details: {
190
353
  version: state.version,
191
354
  anchorsDelta: added.length - removed.length,
192
- warmNew: warmNew.length,
355
+ warmNew: orderedWarm.length,
356
+ surprise: round4(surpriseTotal),
193
357
  changedFiles: changed,
194
358
  semanticChangedFiles: files,
195
359
  deletedFiles: deleted,
@@ -203,8 +367,6 @@ export const sync = async (
203
367
  : deleted.length
204
368
  ? `deleted ${deleted.slice(0, 4).join(", ")}${deleted.length > 4 ? ` (+${deleted.length - 4} more)` : ""}`
205
369
  : "route structure";
206
- const orderedWarm = [...warmNew].sort((a, b) =>
207
- Number(isTestScope(a)) - Number(isTestScope(b)) || a.localeCompare(b));
208
370
  const lines: string[] = [
209
371
  "Repository structure changed.",
210
372
  `Changed: ${changedSummary}`,
@@ -267,6 +429,7 @@ export const sync = async (
267
429
  changedFiles: changed,
268
430
  semanticChangedFiles: files,
269
431
  warmNew: orderedWarm,
432
+ surprise: round4(surpriseTotal),
270
433
  warmReasons,
271
434
  deletedFiles: deleted,
272
435
  ...(embedded ? { pushedFocus: focusTarget } : {}),
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,13 +231,19 @@ 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());
216
243
  if (!cfg.sync.enabled) return;
217
244
  const outcome = await sync(
218
245
  ctx.cwd,
219
- { files: [], budget: cfg.sync.budget, warmFileThreshold: cfg.sync.warmFileThreshold, pushFocus: cfg.sync.pushFocus },
246
+ { files: [], budget: cfg.sync.budget, steerThreshold: cfg.sync.steerThreshold, pushFocus: cfg.sync.pushFocus },
220
247
  undefined,
221
248
  { probe: "cheap" },
222
249
  );
@@ -251,7 +278,7 @@ export default function fovea(pi: ExtensionAPI) {
251
278
  if (!cfg.sync.enabled) return;
252
279
  const outcome = await sync(
253
280
  ctx.cwd,
254
- { files: rels, budget: cfg.sync.budget, warmFileThreshold: cfg.sync.warmFileThreshold, pushFocus: cfg.sync.pushFocus },
281
+ { files: rels, budget: cfg.sync.budget, steerThreshold: cfg.sync.steerThreshold, pushFocus: cfg.sync.pushFocus },
255
282
  undefined,
256
283
  { probe: "cheap" },
257
284
  );
@@ -36,7 +36,7 @@ import {
36
36
 
37
37
  const BOOLEANS = ["true", "false"] as const;
38
38
  const BUDGETS = [256, 512, 1024, 2048, 4096, 8192] as const;
39
- const THRESHOLDS = [1, 2, 3, 5, 8, 13] as const;
39
+ const THRESHOLDS = [0.05, 0.1, 0.15, 0.25, 0.5, 1] as const;
40
40
 
41
41
  const SELECT_LAYOUT: SelectListLayoutOptions = {
42
42
  minPrimaryColumnWidth: 12,
@@ -249,10 +249,10 @@ const buildItems = (
249
249
  "Show a brief notification after changed-but-actionless turns. Default off; clean turns spend no model tokens.",
250
250
  values: BOOLEANS,
251
251
  }),
252
- setting("sync.warmFileThreshold", "Steer threshold", String(config.sync.warmFileThreshold), {
252
+ setting("sync.steerThreshold", "Steer threshold", String(config.sync.steerThreshold), {
253
253
  description:
254
- "How many newly relevant files justify steering the model. Higher means fewer continuations; route changes always steer.",
255
- submenu: numericSubmenu(theme, THRESHOLDS, "Relevance threshold", "Newly relevant file count that steers the model."),
254
+ "Total surprise (channel-weighted heat above the session's sync memory) that justifies steering the model. Higher means fewer continuations; route changes always steer.",
255
+ submenu: numericSubmenu(theme, THRESHOLDS, "Steer threshold", "Surprise mass that steers the model."),
256
256
  }),
257
257
  setting("sync.pushFocus", "Push focus detail", config.sync.pushFocus ? "true" : "false", {
258
258
  description: