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 +1 -1
- package/package.json +1 -1
- package/src/core/config.ts +18 -5
- package/src/core/ops.ts +20 -4
- package/src/core/render.ts +1 -1
- package/src/core/sync.ts +186 -23
- package/src/index.ts +30 -3
- package/src/ui/settings.ts +4 -4
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.
|
|
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
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
19
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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"]))],
|
package/src/core/render.ts
CHANGED
|
@@ -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
|
-
?
|
|
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
|
-
/**
|
|
27
|
-
|
|
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 =>
|
|
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
|
-
|
|
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 =
|
|
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
|
|
263
|
+
let warmMass: Record<string, number> = {};
|
|
164
264
|
let warmReasons: Record<string, string[]> = {};
|
|
265
|
+
let preparedBaseline: SyncBaseline | undefined;
|
|
165
266
|
if (files.length) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
323
|
+
const structuralRed = (added.length - newlyImplicit.length) > 0 ||
|
|
183
324
|
(removed.length > 0 && !degraded) ||
|
|
184
|
-
deleted.some((file) => !isTestScope(file))
|
|
185
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
281
|
+
{ files: rels, budget: cfg.sync.budget, steerThreshold: cfg.sync.steerThreshold, pushFocus: cfg.sync.pushFocus },
|
|
255
282
|
undefined,
|
|
256
283
|
{ probe: "cheap" },
|
|
257
284
|
);
|
package/src/ui/settings.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
252
|
+
setting("sync.steerThreshold", "Steer threshold", String(config.sync.steerThreshold), {
|
|
253
253
|
description:
|
|
254
|
-
"
|
|
255
|
-
submenu: numericSubmenu(theme, THRESHOLDS, "
|
|
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:
|