pi-fovea 0.22.2 → 0.23.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 +21 -3
- package/dist/cli.mjs +27 -7
- package/package.json +1 -1
- package/skills/pi-fovea/SKILL.md +1 -1
- package/src/core/obligations.ts +28 -2
- package/src/core/ops.ts +15 -4
- package/src/core/render.ts +8 -3
- package/src/core/session.ts +2 -0
- package/src/index.ts +21 -2
package/README.md
CHANGED
|
@@ -328,11 +328,21 @@ Focus, sketch, and the structural diffusion operator remain unchanged.
|
|
|
328
328
|
|
|
329
329
|
The obligation ledger keeps the list. Every cascade merges its per-file
|
|
330
330
|
residual mass into a session epoch. Entries stay until evidence moves them. A
|
|
331
|
-
read marks `inspected
|
|
332
|
-
|
|
331
|
+
successful `read` marks `inspected`; a landed `edit` or `write` marks
|
|
332
|
+
`changed` and raises the generation, so a touched entry reopens until a later
|
|
333
|
+
read inspects that generation. A reset clears the epoch. Wall-clock time
|
|
333
334
|
touches nothing here, and disclosure removes nothing. A model that saw a file
|
|
334
335
|
still owes the work the ledger records.
|
|
335
336
|
|
|
337
|
+
The epoch follows the change. `fovea_impact` opens one on the first cascade and
|
|
338
|
+
reuses it while the incoming seeds still share a file with it, so an
|
|
339
|
+
uncommitted diff that keeps growing never loses its checklist. A diff that
|
|
340
|
+
shares no seed with the active epoch is a different change, so the ledger
|
|
341
|
+
rotates: the replacement starts empty and details report `epoch.rotated` with
|
|
342
|
+
the `previous` totals, which keeps the discarded residual visible instead of
|
|
343
|
+
dropping it silently. `markVerified` stays exported for callers that own a real
|
|
344
|
+
verification signal; nothing here invents one.
|
|
345
|
+
|
|
336
346
|
Impact details carry three separate signals:
|
|
337
347
|
|
|
338
348
|
- `expectedButUnchanged`: files with strong directional co-change history that
|
|
@@ -343,7 +353,15 @@ edited.
|
|
|
343
353
|
Total mass stays fixed per connected component, so file masses compare across
|
|
344
354
|
repos of different sizes. Sync gates stay on the older raw scale.
|
|
345
355
|
- `obligations` and `epoch`: the strongest unresolved entries with their
|
|
346
|
-
reasons and generations, plus epoch totals.
|
|
356
|
+
reasons and generations, plus epoch totals. A cascade that rotates the ledger
|
|
357
|
+
adds `epoch.rotated` and `epoch.previous`.
|
|
358
|
+
|
|
359
|
+
A model reads rendered text, not tool details, so `fovea_impact` also renders
|
|
360
|
+
the convergence count as a one-line trailer — `obligations · 9 of 9 unresolved ·
|
|
361
|
+
web/api.ts, server/main.go, openapi.yaml, …` — and drops it entirely once every
|
|
362
|
+
entry is closed. Reads close entries, so the number only falls as work actually
|
|
363
|
+
lands. The trailer is advisory: it never pushes a result past the budget it was
|
|
364
|
+
called with.
|
|
347
365
|
|
|
348
366
|
`docs/heat-diffusion.md` has the full mechanics.
|
|
349
367
|
|
package/dist/cli.mjs
CHANGED
|
@@ -726,12 +726,13 @@ ${allItems.join("\n")}
|
|
|
726
726
|
var revealGroups = (groups, opts) => {
|
|
727
727
|
const ordered = [...groups].sort((a, b) => b.mass - a.mass || (a.label < b.label ? -1 : 1));
|
|
728
728
|
const artifactNote = opts.overflowTo ? ` \u2014 full list saved to ${opts.overflowTo}` : "";
|
|
729
|
-
const renderK = (k, note = artifactNote
|
|
729
|
+
const renderK = (k, note = artifactNote, tail = opts.trailer ? `
|
|
730
|
+
${opts.trailer}` : "") => {
|
|
730
731
|
const body = ordered.slice(0, k).map((gl) => `${gl.label.padEnd(2)} ${gl.detail}`);
|
|
731
732
|
const rest = ordered.length - k;
|
|
732
733
|
const footer = rest > 0 ? [`
|
|
733
734
|
\u2026 ${rest} more groups omitted${note} \u2014 use fovea_focus for detail`] : [];
|
|
734
|
-
return [opts.header, ...body, ...footer].join("\n");
|
|
735
|
+
return [opts.header, ...body, ...footer].join("\n") + tail;
|
|
735
736
|
};
|
|
736
737
|
let hi = ordered.length;
|
|
737
738
|
let kBest = ordered.length;
|
|
@@ -758,6 +759,7 @@ var revealGroups = (groups, opts) => {
|
|
|
758
759
|
text = renderK(kBest, "");
|
|
759
760
|
}
|
|
760
761
|
}
|
|
762
|
+
if (opts.trailer && tokenEstimate(text) > opts.budget) text = renderK(kBest, artifactNote, "");
|
|
761
763
|
return {
|
|
762
764
|
text,
|
|
763
765
|
tokens: tokenEstimate(text),
|
|
@@ -2219,9 +2221,11 @@ var nextEpochOrdinal = (session) => {
|
|
|
2219
2221
|
};
|
|
2220
2222
|
var openEpoch = (session, seedFiles) => {
|
|
2221
2223
|
const ordinal = nextEpochOrdinal(session);
|
|
2224
|
+
const seeds = new Set(seedFiles);
|
|
2222
2225
|
session.obligationEpoch?.ledger.clear();
|
|
2223
2226
|
const epoch = {
|
|
2224
|
-
epochId: `obligation-${ordinal.toString(36)}-${seedFingerprint(
|
|
2227
|
+
epochId: `obligation-${ordinal.toString(36)}-${seedFingerprint(seeds)}`,
|
|
2228
|
+
seeds,
|
|
2225
2229
|
ledger: /* @__PURE__ */ new Map()
|
|
2226
2230
|
};
|
|
2227
2231
|
session.obligationEpoch = epoch;
|
|
@@ -2284,6 +2288,18 @@ var epochStats = (session) => {
|
|
|
2284
2288
|
}
|
|
2285
2289
|
return stats;
|
|
2286
2290
|
};
|
|
2291
|
+
var ensureEpoch = (session, seedFiles) => {
|
|
2292
|
+
const incoming = [...new Set(seedFiles)];
|
|
2293
|
+
const current = session.obligationEpoch;
|
|
2294
|
+
if (!current) {
|
|
2295
|
+
openEpoch(session, incoming);
|
|
2296
|
+
return { rotated: false };
|
|
2297
|
+
}
|
|
2298
|
+
if (!incoming.length || incoming.some((file) => current.seeds.has(file))) return { rotated: false };
|
|
2299
|
+
const previous = epochStats(session);
|
|
2300
|
+
openEpoch(session, incoming);
|
|
2301
|
+
return { rotated: true, previous };
|
|
2302
|
+
};
|
|
2287
2303
|
|
|
2288
2304
|
// src/core/state.ts
|
|
2289
2305
|
import { createHash as createHash4 } from "node:crypto";
|
|
@@ -5711,9 +5727,11 @@ var impact = async (root, args, ensured) => {
|
|
|
5711
5727
|
for (const [k, v] of warmed.slice(0, 2e3)) warmedNodes[k] = v;
|
|
5712
5728
|
}
|
|
5713
5729
|
const foveaSession = getSession(root);
|
|
5714
|
-
|
|
5730
|
+
const epochDecision = ensureEpoch(foveaSession, seedFiles);
|
|
5715
5731
|
if (companionResiduals.size) mergeWarmed(foveaSession, companionResiduals, "unmet co-change companion");
|
|
5716
5732
|
if (fileAgg.size) mergeWarmed(foveaSession, fileAgg, "diffusion residual");
|
|
5733
|
+
const obligations = residual(foveaSession);
|
|
5734
|
+
const epochTotals = epochStats(foveaSession);
|
|
5717
5735
|
const fileEntries = [...fileAgg.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
5718
5736
|
const fileGroups = [];
|
|
5719
5737
|
for (const [file, mass] of fileEntries) {
|
|
@@ -5727,10 +5745,12 @@ var impact = async (root, args, ensured) => {
|
|
|
5727
5745
|
}
|
|
5728
5746
|
const groups = [...anchorHits, ...fileGroups];
|
|
5729
5747
|
const seedNames = seeds.slice(0, 5).map((i) => g.nodes[i].file).join(", ");
|
|
5748
|
+
const trailer = epochTotals.unresolved > 0 ? `obligations \xB7 ${epochTotals.unresolved} of ${epochTotals.total} unresolved \xB7 ${obligations.slice(0, 3).map((entry) => entry.file).join(", ")}${epochTotals.unresolved > 3 ? ", \u2026" : ""}` : "";
|
|
5730
5749
|
const fit = revealGroups(groups, {
|
|
5731
5750
|
header: `fovea impact \xB7 changed: ${seedNames}${seeds.length > 5 ? ", \u2026" : ""} \xB7 likely review order${extractionSuffix(state)}`,
|
|
5732
5751
|
budget: B,
|
|
5733
|
-
overflowTo: overflowArtifact("impact", `${root}|${(args.files ?? []).join(",")}`)
|
|
5752
|
+
overflowTo: overflowArtifact("impact", `${root}|${(args.files ?? []).join(",")}`),
|
|
5753
|
+
trailer
|
|
5734
5754
|
});
|
|
5735
5755
|
return {
|
|
5736
5756
|
text: fit.text,
|
|
@@ -5771,8 +5791,8 @@ var impact = async (root, args, ensured) => {
|
|
|
5771
5791
|
),
|
|
5772
5792
|
// Persistent obligation checklist: survives disclosure and wall-clock
|
|
5773
5793
|
// time; cleared only by evidence transitions or an epoch reset.
|
|
5774
|
-
obligations:
|
|
5775
|
-
epoch:
|
|
5794
|
+
obligations: obligations.slice(0, 10),
|
|
5795
|
+
epoch: epochDecision.rotated ? { ...epochTotals, rotated: true, previous: epochDecision.previous } : epochTotals
|
|
5776
5796
|
}
|
|
5777
5797
|
};
|
|
5778
5798
|
};
|
package/package.json
CHANGED
package/skills/pi-fovea/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ pi-fovea maintains a cross-language code graph of the working repository — rou
|
|
|
12
12
|
1. **`fovea_sketch`** — production-first silhouette. Shipped routes and source regions lead; test and fixture architecture is collapsed. Start here in an unfamiliar repo. ~256–1024 tokens.
|
|
13
13
|
2. **`fovea_focus` `<query>`** — point at a symbol name (close spellings work), route, env key, or file. The active seed and direct relationships always remain visible; previously seen periphery is suppressed only within that focus. A different focus resets to sharp context. Use `path`, `language`, or `kind` to scope output and `fresh: true` for a reproducible full view. Structured details include nodes and suggested read windows.
|
|
14
14
|
3. **`fovea_dwell`** — optional second look. If focus says more results remain, dwell widens only the current focus and returns newly relevant neighbors.
|
|
15
|
-
4. **`fovea_impact`**: blast radius. Seed with repo-relative `files`, symbols, uncommitted changes, or a PR `base`. Output is likely review order with causal channels (calls, imports, literals, routes, tests, inheritance, co-change). Structured details add `expectedButUnchanged` (co-coupled files missing from this diff), `conservedMass` (per-file heat that compares across repo sizes), and `obligations`/`epoch` (a checklist that survives disclosure and time).
|
|
15
|
+
4. **`fovea_impact`**: blast radius. Seed with repo-relative `files`, symbols, uncommitted changes, or a PR `base`. Output is likely review order with causal channels (calls, imports, literals, routes, tests, inheritance, co-change). Structured details add `expectedButUnchanged` (co-coupled files missing from this diff), `conservedMass` (per-file heat that compares across repo sizes), and `obligations`/`epoch` (a checklist that survives disclosure and time). The rendered result carries that same checklist as a one-line trailer — `N of M unresolved` plus its strongest files. Reads close entries, edits reopen them, and a diff sharing no seed with the epoch rotates it; the count only falls when work actually lands, so check it before you call a feature done.
|
|
16
16
|
|
|
17
17
|
All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
|
|
18
18
|
|
package/src/core/obligations.ts
CHANGED
|
@@ -8,7 +8,7 @@ type ObligationEpoch = NonNullable<FoveaSession["obligationEpoch"]>;
|
|
|
8
8
|
type ObligationEntry = ObligationEpoch["ledger"] extends Map<string, infer Entry> ? Entry : never;
|
|
9
9
|
|
|
10
10
|
type ResidualObligation = ObligationEntry & { file: string };
|
|
11
|
-
type EpochStats = {
|
|
11
|
+
export type EpochStats = {
|
|
12
12
|
total: number;
|
|
13
13
|
unresolved: number;
|
|
14
14
|
inspected: number;
|
|
@@ -42,9 +42,13 @@ const nextEpochOrdinal = (session: FoveaSession): number => {
|
|
|
42
42
|
/** Start a fresh obligation epoch and discard every obligation from the old one. */
|
|
43
43
|
export const openEpoch = (session: FoveaSession, seedFiles: Iterable<string>): ObligationEpoch => {
|
|
44
44
|
const ordinal = nextEpochOrdinal(session);
|
|
45
|
+
// The seed baseline is copied rather than aliased: rotation compares against
|
|
46
|
+
// it long after the caller's diff has moved on.
|
|
47
|
+
const seeds = new Set(seedFiles);
|
|
45
48
|
session.obligationEpoch?.ledger.clear();
|
|
46
49
|
const epoch: ObligationEpoch = {
|
|
47
|
-
epochId: `obligation-${ordinal.toString(36)}-${seedFingerprint(
|
|
50
|
+
epochId: `obligation-${ordinal.toString(36)}-${seedFingerprint(seeds)}`,
|
|
51
|
+
seeds,
|
|
48
52
|
ledger: new Map(),
|
|
49
53
|
};
|
|
50
54
|
session.obligationEpoch = epoch;
|
|
@@ -161,3 +165,25 @@ export const epochStats = (session: FoveaSession): EpochStats => {
|
|
|
161
165
|
}
|
|
162
166
|
return stats;
|
|
163
167
|
};
|
|
168
|
+
|
|
169
|
+
export type EpochDecision =
|
|
170
|
+
| { rotated: false }
|
|
171
|
+
| { rotated: true; previous: EpochStats };
|
|
172
|
+
|
|
173
|
+
/** Open the active epoch, or rotate it when the incoming change shares no seed
|
|
174
|
+
* with it. A diff that keeps growing retains every earlier seed, so work in
|
|
175
|
+
* flight never rotates; a diff sharing nothing is the structural signal that
|
|
176
|
+
* the change the ledger was tracking is no longer the one being worked on. A
|
|
177
|
+
* seedless cascade carries no evidence either way and never discards a ledger. */
|
|
178
|
+
export const ensureEpoch = (session: FoveaSession, seedFiles: Iterable<string>): EpochDecision => {
|
|
179
|
+
const incoming = [...new Set(seedFiles)];
|
|
180
|
+
const current = session.obligationEpoch;
|
|
181
|
+
if (!current) {
|
|
182
|
+
openEpoch(session, incoming);
|
|
183
|
+
return { rotated: false };
|
|
184
|
+
}
|
|
185
|
+
if (!incoming.length || incoming.some((file) => current.seeds.has(file))) return { rotated: false };
|
|
186
|
+
const previous = epochStats(session);
|
|
187
|
+
openEpoch(session, incoming);
|
|
188
|
+
return { rotated: true, previous };
|
|
189
|
+
};
|
package/src/core/ops.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { detectBasins } from "./basins.js";
|
|
|
14
14
|
import { classifyLiteral, normalizeLiteral } from "./join.js";
|
|
15
15
|
import { isTestFile } from "./extract.js";
|
|
16
16
|
import { effectiveWeight, expectationResiduals, type CoChangeHistory } from "./cochange.js";
|
|
17
|
-
import { epochStats, mergeWarmed,
|
|
17
|
+
import { ensureEpoch, epochStats, mergeWarmed, residual } from "./obligations.js";
|
|
18
18
|
import type { EdgeEvidence, Graph, NodeKind, NodeRec } from "./types.js";
|
|
19
19
|
import { ensureState, explainPathCoverage } from "./state.js";
|
|
20
20
|
import type { RepoState } from "./state.js";
|
|
@@ -1005,9 +1005,11 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
|
|
|
1005
1005
|
// verify) or the epoch resets — the conservation half of the
|
|
1006
1006
|
// salience/obligation split.
|
|
1007
1007
|
const foveaSession = getSession(root);
|
|
1008
|
-
|
|
1008
|
+
const epochDecision = ensureEpoch(foveaSession, seedFiles);
|
|
1009
1009
|
if (companionResiduals.size) mergeWarmed(foveaSession, companionResiduals, "unmet co-change companion");
|
|
1010
1010
|
if (fileAgg.size) mergeWarmed(foveaSession, fileAgg, "diffusion residual");
|
|
1011
|
+
const obligations = residual(foveaSession);
|
|
1012
|
+
const epochTotals = epochStats(foveaSession);
|
|
1011
1013
|
|
|
1012
1014
|
const fileEntries = [...fileAgg.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
1013
1015
|
const fileGroups: GroupLine[] = [];
|
|
@@ -1026,10 +1028,19 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
|
|
|
1026
1028
|
}
|
|
1027
1029
|
const groups: GroupLine[] = [...anchorHits, ...fileGroups];
|
|
1028
1030
|
const seedNames = seeds.slice(0, 5).map((i) => g.nodes[i]!.file).join(", ");
|
|
1031
|
+
// The model reads rendered text, not details, so the one number that says
|
|
1032
|
+
// whether the change is still closing out has to reach the result itself.
|
|
1033
|
+
// Counts and file names only: the ledger is evidence, never a verdict on the
|
|
1034
|
+
// hypothesis being pursued.
|
|
1035
|
+
const trailer = epochTotals.unresolved > 0
|
|
1036
|
+
? `obligations · ${epochTotals.unresolved} of ${epochTotals.total} unresolved · ` +
|
|
1037
|
+
`${obligations.slice(0, 3).map((entry) => entry.file).join(", ")}${epochTotals.unresolved > 3 ? ", …" : ""}`
|
|
1038
|
+
: "";
|
|
1029
1039
|
const fit = revealGroups(groups, {
|
|
1030
1040
|
header: `fovea impact · changed: ${seedNames}${seeds.length > 5 ? ", …" : ""} · likely review order${extractionSuffix(state)}`,
|
|
1031
1041
|
budget: B,
|
|
1032
1042
|
overflowTo: overflowArtifact("impact", `${root}|${(args.files ?? []).join(",")}`),
|
|
1043
|
+
trailer,
|
|
1033
1044
|
});
|
|
1034
1045
|
return {
|
|
1035
1046
|
text: fit.text,
|
|
@@ -1075,8 +1086,8 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
|
|
|
1075
1086
|
),
|
|
1076
1087
|
// Persistent obligation checklist: survives disclosure and wall-clock
|
|
1077
1088
|
// time; cleared only by evidence transitions or an epoch reset.
|
|
1078
|
-
obligations:
|
|
1079
|
-
epoch:
|
|
1089
|
+
obligations: obligations.slice(0, 10),
|
|
1090
|
+
epoch: epochDecision.rotated ? { ...epochTotals, rotated: true, previous: epochDecision.previous } : epochTotals,
|
|
1080
1091
|
},
|
|
1081
1092
|
};
|
|
1082
1093
|
};
|
package/src/core/render.ts
CHANGED
|
@@ -300,15 +300,17 @@ export interface GroupLine { label: string; mass: number; detail: string; }
|
|
|
300
300
|
|
|
301
301
|
export const revealGroups = (
|
|
302
302
|
groups: GroupLine[],
|
|
303
|
-
opts: { header: string; budget: number; overflowTo?: string },
|
|
303
|
+
opts: { header: string; budget: number; overflowTo?: string; trailer?: string },
|
|
304
304
|
): FitResult => {
|
|
305
305
|
const ordered = [...groups].sort((a, b) => b.mass - a.mass || (a.label < b.label ? -1 : 1));
|
|
306
306
|
const artifactNote = opts.overflowTo ? ` — full list saved to ${opts.overflowTo}` : "";
|
|
307
|
-
|
|
307
|
+
// The trailer is constant across every candidate prefix, so the budget fit
|
|
308
|
+
// stays monotonic in k while still paying for its cost.
|
|
309
|
+
const renderK = (k: number, note = artifactNote, tail = opts.trailer ? `\n${opts.trailer}` : ""): string => {
|
|
308
310
|
const body = ordered.slice(0, k).map((gl) => `${gl.label.padEnd(2)} ${gl.detail}`);
|
|
309
311
|
const rest = ordered.length - k;
|
|
310
312
|
const footer = rest > 0 ? [`\n… ${rest} more groups omitted${note} — use fovea_focus for detail`] : [];
|
|
311
|
-
return [opts.header, ...body, ...footer].join("\n");
|
|
313
|
+
return [opts.header, ...body, ...footer].join("\n") + tail;
|
|
312
314
|
};
|
|
313
315
|
let hi = ordered.length;
|
|
314
316
|
let kBest = ordered.length;
|
|
@@ -335,6 +337,9 @@ export const revealGroups = (
|
|
|
335
337
|
text = renderK(kBest, "");
|
|
336
338
|
}
|
|
337
339
|
}
|
|
340
|
+
// The trailer is advisory; it never gets to break the hard budget a caller
|
|
341
|
+
// paid for, even when the header alone already exceeds it.
|
|
342
|
+
if (opts.trailer && tokenEstimate(text) > opts.budget) text = renderK(kBest, artifactNote, "");
|
|
338
343
|
return {
|
|
339
344
|
text,
|
|
340
345
|
tokens: tokenEstimate(text),
|
package/src/core/session.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface FoveaSession {
|
|
|
30
30
|
/** Persistent review obligations for the active change epoch. */
|
|
31
31
|
obligationEpoch?: {
|
|
32
32
|
epochId: string;
|
|
33
|
+
/** Repo-relative files that opened the epoch; rotation keys on these. */
|
|
34
|
+
seeds: Set<string>;
|
|
33
35
|
ledger: Map<string, {
|
|
34
36
|
mass: number;
|
|
35
37
|
reasons: string[];
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { loadFoveaConfig, type FoveaConfig } from "./core/config.js";
|
|
|
13
13
|
import { hasAstGrep } from "./core/astgrep.js";
|
|
14
14
|
import { ROOT_CACHE_LIMIT } from "./core/asyncutil.js";
|
|
15
15
|
import { coverageSummary, dwell, ensureStateBackground, focus, impact, sketch } from "./core/ops.js";
|
|
16
|
-
import { observeSessionPaths, resetSessions } from "./core/session.js";
|
|
16
|
+
import { getSession, observeSessionPaths, resetSessions } from "./core/session.js";
|
|
17
|
+
import { markEdited, markRead } from "./core/obligations.js";
|
|
17
18
|
import { captureMutation, finishMutation, type MutationCapture } from "./core/provenance.js";
|
|
18
19
|
import { resetSyncBaselines, sync, syncBaselineStore, warmSync } from "./core/sync.js";
|
|
19
20
|
import type { NodeKind } from "./core/types.js";
|
|
@@ -302,6 +303,10 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
302
303
|
const WARM_DEBOUNCE_MS = 250;
|
|
303
304
|
const warmTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
304
305
|
const pendingMutations = new Map<string, MutationCapture>();
|
|
306
|
+
// A read closes an obligation only once it succeeds, so the target is held
|
|
307
|
+
// from tool_execution_start (which carries args) to tool_execution_end
|
|
308
|
+
// (which carries only the result).
|
|
309
|
+
const pendingReads = new Map<string, { root: string; path: string }>();
|
|
305
310
|
const warmAfterEdit = (root: string, cfg: FoveaConfig): void => {
|
|
306
311
|
const rels = turnFiles
|
|
307
312
|
.map((p) => (p.startsWith(root + "/") ? p.slice(root.length + 1) : p))
|
|
@@ -319,6 +324,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
319
324
|
for (const timer of warmTimers.values()) clearTimeout(timer);
|
|
320
325
|
warmTimers.clear();
|
|
321
326
|
pendingMutations.clear();
|
|
327
|
+
pendingReads.clear();
|
|
322
328
|
lifecycleEpoch++;
|
|
323
329
|
turnFiles = [];
|
|
324
330
|
lastSyncError = undefined;
|
|
@@ -333,7 +339,10 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
333
339
|
const args = event.args as { path?: unknown };
|
|
334
340
|
if (ATTENTION_PATH_TOOLS.has(event.toolName) && typeof args.path === "string") {
|
|
335
341
|
const owner = roots.owner(ctx.cwd, args.path);
|
|
336
|
-
if (owner)
|
|
342
|
+
if (owner) {
|
|
343
|
+
observeSessionPaths(owner.root, [owner.path]);
|
|
344
|
+
if (event.toolName === "read") pendingReads.set(event.toolCallId, owner);
|
|
345
|
+
}
|
|
337
346
|
}
|
|
338
347
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
339
348
|
if (typeof args.path !== "string") return;
|
|
@@ -346,10 +355,19 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
346
355
|
// Warm once the file is actually on disk (tool_execution_start fires during
|
|
347
356
|
// preflight, before the write lands); the debounce also coalesces bursts.
|
|
348
357
|
pi.on("tool_execution_end", async (event, ctx) => {
|
|
358
|
+
const read = pendingReads.get(event.toolCallId);
|
|
359
|
+
if (read) {
|
|
360
|
+
pendingReads.delete(event.toolCallId);
|
|
361
|
+
// A failed read revealed nothing, so it cannot close an obligation.
|
|
362
|
+
if (!event.isError) markRead(getSession(read.root), [read.path]);
|
|
363
|
+
}
|
|
349
364
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
350
365
|
const capture = pendingMutations.get(event.toolCallId);
|
|
351
366
|
pendingMutations.delete(event.toolCallId);
|
|
352
367
|
if (!event.isError && capture) {
|
|
368
|
+
// A landed write is a new file generation: the obligation returns to the
|
|
369
|
+
// checklist until a later successful read inspects that generation.
|
|
370
|
+
markEdited(getSession(capture.root), [capture.file]);
|
|
353
371
|
await finishMutation(capture, ctx.sessionManager.getSessionId(), event.toolCallId).catch(() => false);
|
|
354
372
|
}
|
|
355
373
|
if (capture) warmAfterEdit(capture.root, targetConfig(capture.root, ctx));
|
|
@@ -568,6 +586,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
568
586
|
for (const timer of warmTimers.values()) clearTimeout(timer);
|
|
569
587
|
warmTimers.clear();
|
|
570
588
|
pendingMutations.clear();
|
|
589
|
+
pendingReads.clear();
|
|
571
590
|
turnFiles = [];
|
|
572
591
|
resetSessions();
|
|
573
592
|
resetSyncBaselines();
|