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 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`. An edit marks `changed` and raises the generation. A
332
- verified run marks `verified`. A reset clears the epoch. Wall-clock time
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(seedFiles)}`,
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
- if (!foveaSession.obligationEpoch) openEpoch(foveaSession, seedFiles);
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: residual(foveaSession).slice(0, 10),
5775
- epoch: epochStats(foveaSession)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.22.2",
3
+ "version": "0.23.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",
@@ -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). Check `obligations` before you call a feature done.
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
 
@@ -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(seedFiles)}`,
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, openEpoch, residual } from "./obligations.js";
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
- if (!foveaSession.obligationEpoch) openEpoch(foveaSession, seedFiles);
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: residual(foveaSession).slice(0, 10),
1079
- epoch: epochStats(foveaSession),
1089
+ obligations: obligations.slice(0, 10),
1090
+ epoch: epochDecision.rotated ? { ...epochTotals, rotated: true, previous: epochDecision.previous } : epochTotals,
1080
1091
  },
1081
1092
  };
1082
1093
  };
@@ -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
- const renderK = (k: number, note = artifactNote): string => {
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),
@@ -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) observeSessionPaths(owner.root, [owner.path]);
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();