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 +1 -1
- package/src/core/ops.ts +2 -2
- package/src/core/sync.ts +99 -9
- package/src/index.ts +28 -1
package/package.json
CHANGED
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 =>
|
|
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 =
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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());
|