pi-fovea 0.22.2 → 0.24.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
@@ -58,7 +58,7 @@ and test commands in your loop. CI has the final say.
58
58
  | `fovea_sketch` | where is everything? | production-first silhouette plus explicit discovery/extraction coverage; test and fixture architecture stays collapsed |
59
59
  | `fovea_focus` | what is this? | exact symbols/routes/protocol ids, evidenced relationships, path-gap reasons, suggested reads, scopes, and deterministic `fresh` views |
60
60
  | `fovea_dwell` | what else? | widens the current focus, or expires safely when its graph generation changed |
61
- | `fovea_impact` | what does this touch? | hunk-precise seeds, evidenced review paths, unmet co-change companions, and a persistent obligation checklist |
61
+ | `fovea_impact` | what does this touch? | hunk-precise seeds, evidenced review paths, unmet co-change companions, and bounded, revision-aware review memory |
62
62
  | `grep` *(default hybrid)* | graph or text? | bare identifiers, qualified symbols, repo paths, and routes use Fovea; search options and obvious regex retain native grep |
63
63
 
64
64
  Focus normalizes camelCase and common inflections. An approximate name such as
@@ -303,47 +303,29 @@ environment override still turns sync off with:
303
303
  FOVEA_TURN_SYNC=off pi
304
304
  ```
305
305
 
306
- ## Salience and obligations
307
-
308
- `fovea_impact` keeps two clocks on the same graph.
309
-
310
- Heat finds what matters now. Seeds come from the diff. Hunk parsing maps each
311
- change to the symbols that contain it. One unit of mass goes to each changed
312
- file: `0.2` on the file node, `0.8` over the touched symbols in proportion to
313
- `sqrt(changed lines)`. Heat then spreads along static edges and along co-change
314
- partners, and decays with wall-clock time. Edits that resist symbol-level
315
- location fall back to the old file-node seed. New files, deletions, renames,
316
- untracked paths, and oversized diffs all take that path.
317
-
318
- Historical co-change is a decaying **heat prior**, never a permanent graph edge.
319
- It counts up to 400 first-parent integration boundaries: merge net changes count
320
- once, not again through their constituent commits. Explicit `fixup!`/`squash!`
321
- followups join only uniquely resolved older subjects in that window. Time,
322
- author, shared issue numbers, and unlabeled "forgot this" do not group work.
323
- Boundaries are not proof of a semantic feature (release merges can mix work).
324
- Aggregates above 24 tracked files emit no pairs but retain directional touch
325
- counts; two distinct retained units are needed for a pair, three for expectations.
326
- Raw history caching includes shallow-state identity; recency still applies at use.
327
- Focus, sketch, and the structural diffusion operator remain unchanged.
328
-
329
- The obligation ledger keeps the list. Every cascade merges its per-file
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
333
- touches nothing here, and disclosure removes nothing. A model that saw a file
334
- still owes the work the ledger records.
335
-
336
- Impact details carry three separate signals:
337
-
338
- - `expectedButUnchanged`: files with strong directional co-change history that
339
- stayed out of this diff. A Wilson lower bound drives the score, with lift,
340
- support, and recency gates. Treat it as the alarm for the serializer nobody
341
- edited.
342
- - `conservedMass`: the same cascade under degree-corrected random-walk heat.
343
- Total mass stays fixed per connected component, so file masses compare across
344
- repos of different sizes. Sync gates stay on the older raw scale.
345
- - `obligations` and `epoch`: the strongest unresolved entries with their
346
- reasons and generations, plus epoch totals.
306
+ ## Salience and review memory
307
+
308
+ `fovea_impact` separates current salience from remembered source exposure.
309
+
310
+ Heat finds what matters now. Seeds come from the diff. Hunk parsing maps each change to the symbols that contain it. One unit of mass goes to each changed file: `0.2` on the file node, `0.8` over the touched symbols in proportion to `sqrt(changed lines)`. Heat then spreads along static edges and along co-change partners, and decays with wall-clock time. Edits that resist symbol-level location fall back to the old file-node seed. New files, deletions, renames, untracked paths, and oversized diffs all take that path.
311
+
312
+ Historical co-change is a decaying **heat prior**, never a permanent graph edge. It counts up to 400 first-parent integration boundaries: merge net changes count once, not again through their constituent commits. Explicit `fixup!`/`squash!` followups join only uniquely resolved older subjects in that window. Time, author, shared issue numbers, and unlabeled "forgot this" do not group work. Boundaries are not proof of a semantic feature (release merges can mix work). Aggregates above 24 tracked files emit no pairs but retain directional touch counts; two distinct retained units are needed for a pair, three for expectations. Raw history caching includes shallow-state identity; recency still applies at use. Focus, sketch, and the structural diffusion operator remain unchanged.
313
+
314
+ Review memory is **hysteretic**, not conserved work: like a resettable thermal history indicator, its state remembers an earlier event after the heat changes. Each suggested file has independent current `salience` and exposure state: `unseen`, `seen`, or `stale`. Repeated impact calls replace salience rather than accumulating invocation-count debt. Cooling and disclosure do not acknowledge a read. This memory neither proves correctness nor gates completion or starts turns.
315
+
316
+ A successful local `read` records only returned source windows at a content SHA-1. `seen` means source exposure, not whole-file inspection or understanding. The host checks matching text and stable before/after snapshots; requested limits are not treated as returned lines. Failed, diagnostic-only, rewritten, or racing reads earn no acknowledgment. Snapshot capture is bounded to 1 MiB per retained file, with at most 16 merged windows per revision (`windowsOmitted` reports loss). Reads before a file enters review memory are not tracked. Thus `unread` means **no matching read observed while retained**, not proof that nobody read it.
317
+
318
+ A changed content hash makes previous exposure `stale`, including comment-only, shell, Fabric, and editor changes when the graph next refreshes. Reverting a file does not erase the stale marker; another matching read does. This is file-local exposure history, not a sound dependency-based verification system. Semantic surprise and turn steering retain their existing separate rules.
319
+
320
+ Memory holds at most 512 files, preferring current salience. `evicted` counts retained entries actually forgotten; `omitted` counts new candidates not retained in the latest cascade. Repeated omitted candidates do not accumulate debt. A disjoint seed set starts a new review epoch and reports the discarded totals as `review.previous`; overlapping/growing changes retain their epoch. Reset, reload, and session replacement clear this advisory memory rather than persist it.
321
+
322
+ Impact details carry three distinct signals:
323
+
324
+ - `expectedButUnchanged`: likely missing companions inferred from directional co-change history. These are review suggestions, not required edits.
325
+ - `conservedMass`: degree-corrected random-walk heat; mathematical conservation of diffusion mass is unrelated to review accounting. Sync keeps its raw scale.
326
+ - `review`: retained entries with salience, reasons, revision, exposure windows, `unseen`/`seen`/`stale` totals, epoch, and retention-loss information. This replaces the former `obligations` and top-level `epoch` fields; the verification transition has been removed rather than relabeled.
327
+
328
+ The budgeted trailer says `review memory · 3 unread · 1 stale · 2 seen (windows only)` and names the strongest pending suggestions. Evictions and prior-epoch loss appear there when budget permits and always in structured details. Cold pending entries remain accessible in the full impact overflow list. Zero unread or stale entries means no pending *retained review suggestions*, never feature completion. The CLI has no host read events; inspection tracking belongs to the in-session extension.
347
329
 
348
330
  `docs/heat-diffusion.md` has the full mechanics.
349
331
 
package/dist/cli.mjs CHANGED
@@ -377,13 +377,13 @@ var buildCsr = (g) => {
377
377
  pairs.push([a, b, w2]);
378
378
  }
379
379
  pairs.sort((x, y) => x[0] - y[0]);
380
- const counts = new Uint32Array(n);
380
+ const counts2 = new Uint32Array(n);
381
381
  for (const [a, b] of pairs) {
382
- counts[a]++;
383
- counts[b]++;
382
+ counts2[a]++;
383
+ counts2[b]++;
384
384
  }
385
385
  const rowPtr = new Uint32Array(n + 1);
386
- for (let i = 0; i < n; i++) rowPtr[i + 1] = rowPtr[i] + counts[i];
386
+ for (let i = 0; i < n; i++) rowPtr[i + 1] = rowPtr[i] + counts2[i];
387
387
  const col = new Uint32Array(rowPtr[n]);
388
388
  const w = new Float64Array(rowPtr[n]);
389
389
  const cursor = new Uint32Array(n);
@@ -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),
@@ -771,6 +773,83 @@ var revealGroups = (groups, opts) => {
771
773
 
772
774
  // src/core/session.ts
773
775
  import { isAbsolute, relative, resolve, sep } from "node:path";
776
+
777
+ // src/core/review.ts
778
+ var FILE_LIMIT = 512;
779
+ var counts = (memory) => {
780
+ const result = {
781
+ total: memory?.entries.size ?? 0,
782
+ unseen: 0,
783
+ seen: 0,
784
+ stale: 0,
785
+ evicted: memory?.evicted ?? 0,
786
+ omitted: memory?.omitted ?? 0
787
+ };
788
+ for (const entry of memory?.entries.values() ?? []) result[entry.status]++;
789
+ return result;
790
+ };
791
+ var bySalience = (a, b) => b[1].salience - a[1].salience || a[0].localeCompare(b[0]);
792
+ var observeReviewRevision = (memory, file, revision) => {
793
+ const entry = memory?.entries.get(file);
794
+ if (!entry || entry.revision === revision) return;
795
+ entry.revision = revision;
796
+ entry.status = entry.exposure ? "stale" : "unseen";
797
+ };
798
+ var updateReviewMemory = (session, seeds, samples) => {
799
+ const incoming = [...new Set(seeds)];
800
+ let memory = session.reviewMemory;
801
+ if (!memory || incoming.length && !incoming.some((file) => memory.seeds.has(file))) {
802
+ const previous = memory ? counts(memory) : void 0;
803
+ const epoch = (memory?.epoch ?? 0) + 1;
804
+ memory?.entries.clear();
805
+ memory = { epoch, seeds: new Set(incoming), entries: /* @__PURE__ */ new Map(), evicted: 0, omitted: 0, previous };
806
+ session.reviewMemory = memory;
807
+ }
808
+ const retained = new Set(memory.entries.keys());
809
+ for (const entry of memory.entries.values()) entry.salience = 0;
810
+ for (const [file, sample] of samples) {
811
+ if (!Number.isFinite(sample.salience) || sample.salience <= 0) continue;
812
+ let entry = memory.entries.get(file);
813
+ if (!entry) {
814
+ entry = { salience: 0, reasons: [], revision: sample.revision, status: "unseen" };
815
+ memory.entries.set(file, entry);
816
+ }
817
+ observeReviewRevision(memory, file, sample.revision);
818
+ entry.salience = sample.salience;
819
+ entry.reasons = [...new Set(sample.reasons)].sort();
820
+ }
821
+ memory.omitted = 0;
822
+ for (const [file] of [...memory.entries].sort(bySalience).slice(FILE_LIMIT)) {
823
+ memory.entries.delete(file);
824
+ if (retained.has(file)) memory.evicted++;
825
+ else memory.omitted++;
826
+ }
827
+ return memory;
828
+ };
829
+ var reviewReport = (memory) => ({
830
+ ...counts(memory),
831
+ epoch: memory?.epoch,
832
+ previous: memory?.previous ? { ...memory.previous } : void 0,
833
+ entries: [...memory?.entries ?? []].sort((a, b) => Number(a[1].status === "seen") - Number(b[1].status === "seen") || bySalience(a, b)).map(([file, entry]) => ({
834
+ ...entry,
835
+ file,
836
+ reasons: [...entry.reasons],
837
+ exposure: entry.exposure ? { ...entry.exposure, windows: entry.exposure.windows.map((w) => ({ ...w })) } : void 0
838
+ }))
839
+ });
840
+ var reviewTrailer = (report) => {
841
+ if (!report.total && !report.previous && !report.evicted && !report.omitted) return "";
842
+ const files = report.entries.filter((entry) => entry.status !== "seen").slice(0, 3).map((entry) => entry.file);
843
+ const notes = [
844
+ files.length ? files.join(", ") : "",
845
+ report.evicted ? `${report.evicted} entries evicted` : "",
846
+ report.omitted ? `${report.omitted} candidates not retained` : "",
847
+ report.previous ? `prior epoch cleared: ${report.previous.total} entries (${report.previous.unseen} unread, ${report.previous.stale} stale)` : ""
848
+ ].filter(Boolean);
849
+ return `review memory \xB7 ${report.unseen} unread \xB7 ${report.stale} stale \xB7 ${report.seen} seen (windows only)` + (notes.length ? ` \xB7 ${notes.join(" \xB7 ")}` : "");
850
+ };
851
+
852
+ // src/core/session.ts
774
853
  var FOCUS_T0 = 2;
775
854
  var TK_ORDER = 80;
776
855
  var sessions = /* @__PURE__ */ new Map();
@@ -795,7 +874,11 @@ var getSession = (root) => {
795
874
  tkKey: ""
796
875
  };
797
876
  sessions.set(root, s);
798
- while (sessions.size > ROOT_CACHE_LIMIT) sessions.delete(sessions.keys().next().value);
877
+ while (sessions.size > ROOT_CACHE_LIMIT) {
878
+ const oldest = sessions.keys().next().value;
879
+ sessions.get(oldest)?.reviewMemory?.entries.clear();
880
+ sessions.delete(oldest);
881
+ }
799
882
  return s;
800
883
  };
801
884
  var repoRelativePath = (root, input) => {
@@ -818,6 +901,10 @@ var observeSessionPaths = (root, paths) => {
818
901
  }
819
902
  return [...session.syncScopes].sort();
820
903
  };
904
+ var refreshSessionReviews = (root, revisionFor) => {
905
+ const memory = sessions.get(root)?.reviewMemory;
906
+ for (const file of memory?.entries.keys() ?? []) observeReviewRevision(memory, file, revisionFor(file));
907
+ };
821
908
  var clearSessionFocus = (session) => {
822
909
  session.t = FOCUS_T0;
823
910
  session.seeds = [];
@@ -2199,92 +2286,6 @@ var groupPairs = (pairs, commits) => {
2199
2286
  return out;
2200
2287
  };
2201
2288
 
2202
- // src/core/obligations.ts
2203
- var LEDGER_LIMIT = 512;
2204
- var seedFingerprint = (seedFiles) => {
2205
- const files = [...new Set(seedFiles)].sort();
2206
- let hash = 2166136261;
2207
- for (const file of files) {
2208
- for (let i = 0; i < file.length; i++) {
2209
- hash = Math.imul(hash ^ file.charCodeAt(i), 16777619);
2210
- }
2211
- hash = Math.imul(hash ^ 0, 16777619);
2212
- }
2213
- return (hash >>> 0).toString(36);
2214
- };
2215
- var nextEpochOrdinal = (session) => {
2216
- const encoded = session.obligationEpoch?.epochId.match(/^obligation-([0-9a-z]+)-/)?.[1];
2217
- const previous = encoded === void 0 ? 0 : Number.parseInt(encoded, 36);
2218
- return Number.isSafeInteger(previous) && previous >= 0 ? previous + 1 : 1;
2219
- };
2220
- var openEpoch = (session, seedFiles) => {
2221
- const ordinal = nextEpochOrdinal(session);
2222
- session.obligationEpoch?.ledger.clear();
2223
- const epoch = {
2224
- epochId: `obligation-${ordinal.toString(36)}-${seedFingerprint(seedFiles)}`,
2225
- ledger: /* @__PURE__ */ new Map()
2226
- };
2227
- session.obligationEpoch = epoch;
2228
- return epoch;
2229
- };
2230
- var activeEpoch = (session) => session.obligationEpoch ?? openEpoch(session, []);
2231
- var enforceBound = (ledger) => {
2232
- if (ledger.size <= LEDGER_LIMIT) return;
2233
- const weakest = [...ledger.entries()].sort(
2234
- (a, b) => a[1].mass - b[1].mass || b[0].localeCompare(a[0])
2235
- );
2236
- for (let i = 0; i < weakest.length - LEDGER_LIMIT; i++) {
2237
- ledger.delete(weakest[i][0]);
2238
- }
2239
- };
2240
- var mergeWarmed = (session, fileMass, reason) => {
2241
- const ledger = activeEpoch(session).ledger;
2242
- for (const [file, mass] of fileMass) {
2243
- if (!Number.isFinite(mass) || mass <= 0) continue;
2244
- const entry = ledger.get(file);
2245
- if (entry) {
2246
- const sum = entry.mass + mass;
2247
- entry.mass = Number.isFinite(sum) ? sum : Number.MAX_VALUE;
2248
- if (reason && !entry.reasons.includes(reason)) entry.reasons.push(reason);
2249
- continue;
2250
- }
2251
- ledger.set(file, {
2252
- mass,
2253
- reasons: reason ? [reason] : [],
2254
- generation: 0,
2255
- status: "unresolved"
2256
- });
2257
- }
2258
- enforceBound(ledger);
2259
- };
2260
- var residual = (session) => {
2261
- const ledger = session.obligationEpoch?.ledger;
2262
- if (!ledger) return [];
2263
- return [...ledger.entries()].filter(([, entry]) => entry.status === "unresolved").sort((a, b) => b[1].mass - a[1].mass || a[0].localeCompare(b[0])).map(([file, entry]) => ({
2264
- file,
2265
- mass: entry.mass,
2266
- reasons: [...entry.reasons],
2267
- generation: entry.generation,
2268
- status: "unresolved"
2269
- }));
2270
- };
2271
- var epochStats = (session) => {
2272
- const stats = {
2273
- total: 0,
2274
- unresolved: 0,
2275
- inspected: 0,
2276
- changed: 0,
2277
- verified: 0,
2278
- mass: 0
2279
- };
2280
- for (const entry of session.obligationEpoch?.ledger.values() ?? []) {
2281
- stats.total++;
2282
- stats.mass += entry.mass;
2283
- stats[entry.status]++;
2284
- }
2285
- return stats;
2286
- };
2287
-
2288
2289
  // src/core/state.ts
2289
2290
  import { createHash as createHash4 } from "node:crypto";
2290
2291
  import { stat as stat2 } from "node:fs/promises";
@@ -4815,14 +4816,17 @@ var ensureState = (root, opts = {}) => {
4815
4816
  const pending = inflight.get(root);
4816
4817
  if (pending) return pending;
4817
4818
  const warm = touch(root);
4818
- const p = warm ? refreshState(warm, opts.hints, opts.force) : (async () => {
4819
+ const p = (warm ? refreshState(warm, opts.hints, opts.force) : (async () => {
4819
4820
  const st = await stat2(root).catch(() => void 0);
4820
4821
  if (!st?.isDirectory()) throw new Error(`fovea: root does not exist or is not a directory: ${root}`);
4821
4822
  const state = await buildState(root);
4822
4823
  states.set(root, state);
4823
4824
  evictLru();
4824
4825
  return state;
4825
- })();
4826
+ })()).then((state) => {
4827
+ refreshSessionReviews(root, (file) => state.facts[file]?.sha1 ?? state.store.failedSha.get(file));
4828
+ return state;
4829
+ });
4826
4830
  inflight.set(root, p);
4827
4831
  const clear = () => {
4828
4832
  if (inflight.get(root) === p) inflight.delete(root);
@@ -5569,10 +5573,14 @@ var impact = async (root, args, ensured) => {
5569
5573
  const gaps = requestedCoverage.filter((entry) => entry.status !== "indexed").map((entry) => `
5570
5574
  ! ${entry.path ?? entry.requested}: ${entry.reason}.`).join("");
5571
5575
  const text = `fovea impact: no seed files (repo clean or paths unknown). Pass files: [...] or symbols: [...] for a what-if cascade.${gaps}`;
5576
+ const session = getSession(root);
5577
+ if (session.reviewMemory) updateReviewMemory(session, [], /* @__PURE__ */ new Map());
5578
+ const review2 = reviewReport(session.reviewMemory);
5579
+ const fit2 = revealGroups([], { header: text, budget: B, trailer: reviewTrailer(review2) });
5572
5580
  return {
5573
- text,
5574
- tokens: tokenEstimate(text),
5575
- details: { seeds: 0, requestedCoverage, ...extractionDetails(state) }
5581
+ text: fit2.text,
5582
+ tokens: fit2.tokens,
5583
+ details: { seeds: 0, requestedCoverage, ...extractionDetails(state), review: review2 }
5576
5584
  };
5577
5585
  }
5578
5586
  const seeds = [...seedSet];
@@ -5710,10 +5718,18 @@ var impact = async (root, args, ensured) => {
5710
5718
  warmed.sort((a, b) => b[1].m - a[1].m);
5711
5719
  for (const [k, v] of warmed.slice(0, 2e3)) warmedNodes[k] = v;
5712
5720
  }
5713
- const foveaSession = getSession(root);
5714
- if (!foveaSession.obligationEpoch) openEpoch(foveaSession, seedFiles);
5715
- if (companionResiduals.size) mergeWarmed(foveaSession, companionResiduals, "unmet co-change companion");
5716
- if (fileAgg.size) mergeWarmed(foveaSession, fileAgg, "diffusion residual");
5721
+ const samples = /* @__PURE__ */ new Map();
5722
+ for (const file of /* @__PURE__ */ new Set([...fileAgg.keys(), ...companionResiduals.keys()])) {
5723
+ samples.set(file, {
5724
+ salience: Math.max(fileAgg.get(file) ?? 0, companionResiduals.get(file) ?? 0),
5725
+ reasons: [
5726
+ ...reasonByFile.get(file) ?? [],
5727
+ ...companionResiduals.has(file) ? ["unmet co-change companion"] : []
5728
+ ],
5729
+ revision: state.facts[file]?.sha1 ?? state.store.failedSha?.get(file)
5730
+ });
5731
+ }
5732
+ const review = reviewReport(updateReviewMemory(getSession(root), seedFiles, samples));
5717
5733
  const fileEntries = [...fileAgg.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
5718
5734
  const fileGroups = [];
5719
5735
  for (const [file, mass] of fileEntries) {
@@ -5725,12 +5741,15 @@ var impact = async (root, args, ensured) => {
5725
5741
  detail: `via ${reasons.join(", ")}${top ? ` \xB7 top: ${top}` : ""}`
5726
5742
  });
5727
5743
  }
5728
- const groups = [...anchorHits, ...fileGroups];
5744
+ const remembered = review.entries.filter((entry) => entry.status !== "seen" && !fileAgg.has(entry.file)).map((entry) => ({ label: entry.file, mass: entry.salience, detail: `review memory: ${entry.status}` }));
5745
+ const groups = [...anchorHits, ...fileGroups, ...remembered];
5729
5746
  const seedNames = seeds.slice(0, 5).map((i) => g.nodes[i].file).join(", ");
5747
+ const trailer = reviewTrailer(review);
5730
5748
  const fit = revealGroups(groups, {
5731
5749
  header: `fovea impact \xB7 changed: ${seedNames}${seeds.length > 5 ? ", \u2026" : ""} \xB7 likely review order${extractionSuffix(state)}`,
5732
5750
  budget: B,
5733
- overflowTo: overflowArtifact("impact", `${root}|${(args.files ?? []).join(",")}`)
5751
+ overflowTo: overflowArtifact("impact", `${root}|${(args.files ?? []).join(",")}`),
5752
+ trailer
5734
5753
  });
5735
5754
  return {
5736
5755
  text: fit.text,
@@ -5738,7 +5757,7 @@ var impact = async (root, args, ensured) => {
5738
5757
  details: {
5739
5758
  seeds: seeds.length,
5740
5759
  historyPartners,
5741
- warmed: groups.length,
5760
+ warmed: anchorHits.length + fileGroups.length,
5742
5761
  truncated: fit.truncated,
5743
5762
  requestedCoverage,
5744
5763
  ...extractionDetails(state),
@@ -5769,10 +5788,8 @@ var impact = async (root, args, ensured) => {
5769
5788
  conservedMass: Object.fromEntries(
5770
5789
  [...conservedByFile.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([file, mass]) => [file, Number(mass.toFixed(6))])
5771
5790
  ),
5772
- // Persistent obligation checklist: survives disclosure and wall-clock
5773
- // time; cleared only by evidence transitions or an epoch reset.
5774
- obligations: residual(foveaSession).slice(0, 10),
5775
- epoch: epochStats(foveaSession)
5791
+ // Bounded exposure history, not a completion or verification ledger.
5792
+ review
5776
5793
  }
5777
5794
  };
5778
5795
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.22.2",
3
+ "version": "0.24.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` (likely co-change companions, not required edits), `conservedMass` (comparable diffusion heat), and `review` (bounded, hysteretic source-exposure memory). The trailer reports unread/stale/seen suggestions, not unfinished obligations. Matching successful reads record returned windows at a content revision; later content drift makes that exposure stale. Repeated impact replaces salience rather than accumulating work. Eviction and disjoint-epoch loss are disclosed; reset/reload clears the memory. `seen` means only that source windows were returned, and zero pending suggestions never means the feature is complete. Keep tests and acceptance checks in the host workflow.
16
16
 
17
17
  All four accept `maxTokens` (256–16000). Budget is roughly 4 chars per token.
18
18
 
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 { reviewReport, reviewTrailer, updateReviewMemory, type ReviewSample } from "./review.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";
@@ -813,10 +813,14 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
813
813
  .map((entry) => `\n! ${entry.path ?? entry.requested}: ${entry.reason}.`)
814
814
  .join("");
815
815
  const text = `fovea impact: no seed files (repo clean or paths unknown). Pass files: [...] or symbols: [...] for a what-if cascade.${gaps}`;
816
+ const session = getSession(root);
817
+ if (session.reviewMemory) updateReviewMemory(session, [], new Map());
818
+ const review = reviewReport(session.reviewMemory);
819
+ const fit = revealGroups([], { header: text, budget: B, trailer: reviewTrailer(review) });
816
820
  return {
817
- text,
818
- tokens: tokenEstimate(text),
819
- details: { seeds: 0, requestedCoverage, ...extractionDetails(state) },
821
+ text: fit.text,
822
+ tokens: fit.tokens,
823
+ details: { seeds: 0, requestedCoverage, ...extractionDetails(state), review },
820
824
  };
821
825
  }
822
826
  const seeds = [...seedSet];
@@ -1000,14 +1004,18 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
1000
1004
  for (const [k, v] of warmed.slice(0, 2000)) warmedNodes[k] = v;
1001
1005
  }
1002
1006
 
1003
- // Obligation ledger: merge this cascade's residual mass additively. Unlike
1004
- // novelty heat, entries persist until evidence transitions them (read/edit/
1005
- // verify) or the epoch resets — the conservation half of the
1006
- // salience/obligation split.
1007
- const foveaSession = getSession(root);
1008
- if (!foveaSession.obligationEpoch) openEpoch(foveaSession, seedFiles);
1009
- if (companionResiduals.size) mergeWarmed(foveaSession, companionResiduals, "unmet co-change companion");
1010
- if (fileAgg.size) mergeWarmed(foveaSession, fileAgg, "diffusion residual");
1007
+ // Current salience ranks; hysteretic exposure survives cooling. Repeating
1008
+ // this cascade cannot manufacture more work or certify its completion.
1009
+ const samples = new Map<string, ReviewSample>();
1010
+ for (const file of new Set([...fileAgg.keys(), ...companionResiduals.keys()])) {
1011
+ samples.set(file, {
1012
+ salience: Math.max(fileAgg.get(file) ?? 0, companionResiduals.get(file) ?? 0),
1013
+ reasons: [...(reasonByFile.get(file) ?? []),
1014
+ ...(companionResiduals.has(file) ? ["unmet co-change companion"] : [])],
1015
+ revision: state.facts[file]?.sha1 ?? state.store.failedSha?.get(file),
1016
+ });
1017
+ }
1018
+ const review = reviewReport(updateReviewMemory(getSession(root), seedFiles, samples));
1011
1019
 
1012
1020
  const fileEntries = [...fileAgg.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1013
1021
  const fileGroups: GroupLine[] = [];
@@ -1024,12 +1032,17 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
1024
1032
  detail: `via ${reasons.join(", ")}${top ? ` · top: ${top}` : ""}`,
1025
1033
  });
1026
1034
  }
1027
- const groups: GroupLine[] = [...anchorHits, ...fileGroups];
1035
+ // Retained cold suggestions remain reachable in the full overflow artifact.
1036
+ const remembered = review.entries.filter((entry) => entry.status !== "seen" && !fileAgg.has(entry.file))
1037
+ .map((entry) => ({ label: entry.file, mass: entry.salience, detail: `review memory: ${entry.status}` }));
1038
+ const groups: GroupLine[] = [...anchorHits, ...fileGroups, ...remembered];
1028
1039
  const seedNames = seeds.slice(0, 5).map((i) => g.nodes[i]!.file).join(", ");
1040
+ const trailer = reviewTrailer(review);
1029
1041
  const fit = revealGroups(groups, {
1030
1042
  header: `fovea impact · changed: ${seedNames}${seeds.length > 5 ? ", …" : ""} · likely review order${extractionSuffix(state)}`,
1031
1043
  budget: B,
1032
1044
  overflowTo: overflowArtifact("impact", `${root}|${(args.files ?? []).join(",")}`),
1045
+ trailer,
1033
1046
  });
1034
1047
  return {
1035
1048
  text: fit.text,
@@ -1037,7 +1050,7 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
1037
1050
  details: {
1038
1051
  seeds: seeds.length,
1039
1052
  historyPartners,
1040
- warmed: groups.length,
1053
+ warmed: anchorHits.length + fileGroups.length,
1041
1054
  truncated: fit.truncated,
1042
1055
  requestedCoverage,
1043
1056
  ...extractionDetails(state),
@@ -1073,10 +1086,8 @@ export const impact = async (root: string, args: ImpactArgs, ensured?: RepoState
1073
1086
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1074
1087
  .map(([file, mass]) => [file, Number(mass.toFixed(6))]),
1075
1088
  ),
1076
- // Persistent obligation checklist: survives disclosure and wall-clock
1077
- // time; cleared only by evidence transitions or an epoch reset.
1078
- obligations: residual(foveaSession).slice(0, 10),
1079
- epoch: epochStats(foveaSession),
1089
+ // Bounded exposure history, not a completion or verification ledger.
1090
+ review,
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),
@@ -0,0 +1,66 @@
1
+ // Best-effort local read exposure. Unknown/rewritten results remain unacknowledged.
2
+ import { createHash } from "node:crypto";
3
+ import { open } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { observeReviewRevision, recordReviewRead, type ReviewMemory } from "./review.js";
6
+
7
+ // Keep snapshot work bounded even for a read of a giant or growing file.
8
+ const MAX_BYTES = 1024 * 1024;
9
+ const snapshot = async (path: string): Promise<{ revision: string; text: string } | undefined> => {
10
+ const handle = await open(path, "r").catch(() => undefined);
11
+ if (!handle) return;
12
+ try {
13
+ const stat = await handle.stat();
14
+ if (!stat.isFile() || stat.size > MAX_BYTES) return;
15
+ const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_BYTES + 1));
16
+ let size = 0;
17
+ while (size < bytes.length) {
18
+ const read = await handle.read(bytes, size, bytes.length - size, size);
19
+ if (!read.bytesRead) break;
20
+ size += read.bytesRead;
21
+ }
22
+ if (size > stat.size || size > MAX_BYTES) return;
23
+ const content = bytes.subarray(0, size);
24
+ return { revision: createHash("sha1").update(content).digest("hex"), text: content.toString("utf8") };
25
+ } catch {
26
+ return undefined;
27
+ } finally {
28
+ await handle.close();
29
+ }
30
+ };
31
+
32
+ export const captureReviewRead = async (
33
+ memory: ReviewMemory | undefined, root: string, file: string, args: { offset?: unknown; limit?: unknown },
34
+ ) => {
35
+ const entry = memory?.entries.get(file);
36
+ if (!memory || !entry) return;
37
+ const start = args.offset === undefined ? 1 : args.offset;
38
+ const limit = args.limit;
39
+ if (typeof start !== "number" || !Number.isSafeInteger(start) || start < 1 ||
40
+ (limit !== undefined && (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1))) return;
41
+ const before = await snapshot(join(root, file));
42
+ if (!before || memory.entries.get(file) !== entry) return;
43
+ return { memory, entry, root, file, start, limit: limit as number | undefined, before };
44
+ };
45
+
46
+ export const finishReviewRead = async (
47
+ capture: NonNullable<Awaited<ReturnType<typeof captureReviewRead>>>, result: unknown,
48
+ ): Promise<void> => {
49
+ const { memory, entry, root, file, before, start, limit } = capture;
50
+ if (memory.entries.get(file) !== entry) return;
51
+ const after = await snapshot(join(root, file));
52
+ if (memory.entries.get(file) !== entry) return;
53
+ observeReviewRevision(memory, file, after?.revision);
54
+ if (!after || after.revision !== before.revision) return;
55
+ const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content;
56
+ if (!Array.isArray(content) || content.length !== 1 || content[0]?.type !== "text" || typeof content[0].text !== "string") return;
57
+ // Native read appends either a user-limit notice or a byte/line truncation
58
+ // notice. Compare the actual returned source prefix, never the requested limit.
59
+ const body = content[0].text.replace(/\n\n\[(?:\d+ more lines in file\. Use offset=\d+ to continue\.|Showing lines \d+-\d+ of \d+(?: \([^\n]*\))?\. Use offset=\d+ to continue\.)\]$/, "");
60
+ if (!body.length) return;
61
+ const lines = body.split("\n").length;
62
+ if (limit !== undefined && lines > limit) return;
63
+ const source = before.text.split("\n");
64
+ if (start + lines - 1 > source.length || source.slice(start - 1, start - 1 + lines).join("\n") !== body) return;
65
+ recordReviewRead(memory, file, after.revision, { start, end: start + lines - 1 });
66
+ };
@@ -0,0 +1,136 @@
1
+ // Hysteretic review memory: salience changes with each cascade, while exposure
2
+ // survives cooling. This is bounded navigation memory, never completion evidence.
3
+ import type { FoveaSession } from "./session.js";
4
+
5
+ interface ReadWindow { start: number; end: number }
6
+ interface Exposure {
7
+ revision: string;
8
+ windows: ReadWindow[];
9
+ windowsOmitted: boolean;
10
+ }
11
+ interface ReviewEntry {
12
+ salience: number;
13
+ reasons: string[];
14
+ revision: string | undefined;
15
+ status: "unseen" | "seen" | "stale";
16
+ exposure?: Exposure;
17
+ }
18
+ interface ReviewCounts {
19
+ total: number;
20
+ unseen: number;
21
+ seen: number;
22
+ stale: number;
23
+ evicted: number;
24
+ omitted: number;
25
+ }
26
+ export interface ReviewMemory {
27
+ epoch: number;
28
+ seeds: Set<string>;
29
+ entries: Map<string, ReviewEntry>;
30
+ evicted: number;
31
+ omitted: number;
32
+ previous?: ReviewCounts;
33
+ }
34
+ export interface ReviewSample {
35
+ salience: number;
36
+ reasons: string[];
37
+ revision: string | undefined;
38
+ }
39
+ const FILE_LIMIT = 512;
40
+ const WINDOW_LIMIT = 16;
41
+ const counts = (memory?: ReviewMemory): ReviewCounts => {
42
+ const result = { total: memory?.entries.size ?? 0, unseen: 0, seen: 0, stale: 0,
43
+ evicted: memory?.evicted ?? 0, omitted: memory?.omitted ?? 0 };
44
+ for (const entry of memory?.entries.values() ?? []) result[entry.status]++;
45
+ return result;
46
+ };
47
+ const bySalience = (a: [string, ReviewEntry], b: [string, ReviewEntry]): number =>
48
+ b[1].salience - a[1].salience || a[0].localeCompare(b[0]);
49
+
50
+ /** A changed or unavailable source makes previous exposure stale, even on revert. */
51
+ export const observeReviewRevision = (memory: ReviewMemory | undefined, file: string, revision: string | undefined): void => {
52
+ const entry = memory?.entries.get(file);
53
+ if (!entry || entry.revision === revision) return;
54
+ entry.revision = revision;
55
+ entry.status = entry.exposure ? "stale" : "unseen";
56
+ };
57
+
58
+ /** Replace current salience, never accumulate invocation-count debt. */
59
+ export const updateReviewMemory = (
60
+ session: FoveaSession,
61
+ seeds: Iterable<string>,
62
+ samples: ReadonlyMap<string, ReviewSample>,
63
+ ): ReviewMemory => {
64
+ const incoming = [...new Set(seeds)];
65
+ let memory = session.reviewMemory;
66
+ if (!memory || (incoming.length && !incoming.some((file) => memory!.seeds.has(file)))) {
67
+ const previous = memory ? counts(memory) : undefined;
68
+ const epoch = (memory?.epoch ?? 0) + 1;
69
+ memory?.entries.clear();
70
+ memory = { epoch, seeds: new Set(incoming), entries: new Map(), evicted: 0, omitted: 0, previous };
71
+ session.reviewMemory = memory;
72
+ }
73
+ const retained = new Set(memory.entries.keys());
74
+ for (const entry of memory.entries.values()) entry.salience = 0;
75
+ for (const [file, sample] of samples) {
76
+ if (!Number.isFinite(sample.salience) || sample.salience <= 0) continue;
77
+ let entry = memory.entries.get(file);
78
+ if (!entry) {
79
+ entry = { salience: 0, reasons: [], revision: sample.revision, status: "unseen" };
80
+ memory.entries.set(file, entry);
81
+ }
82
+ observeReviewRevision(memory, file, sample.revision);
83
+ entry.salience = sample.salience;
84
+ entry.reasons = [...new Set(sample.reasons)].sort();
85
+ }
86
+ memory.omitted = 0;
87
+ for (const [file] of [...memory.entries].sort(bySalience).slice(FILE_LIMIT)) {
88
+ memory.entries.delete(file);
89
+ if (retained.has(file)) memory.evicted++;
90
+ else memory.omitted++;
91
+ }
92
+ return memory;
93
+ };
94
+
95
+ /** Record returned source lines, not whole-file inspection or understanding. */
96
+ export const recordReviewRead = (memory: ReviewMemory, file: string, revision: string, window: ReadWindow): void => {
97
+ const entry = memory.entries.get(file);
98
+ if (!entry || !revision || !Number.isSafeInteger(window.start) || !Number.isSafeInteger(window.end) ||
99
+ window.start < 1 || window.end < window.start) return;
100
+ observeReviewRevision(memory, file, revision);
101
+ const previous = entry.exposure?.revision === revision ? entry.exposure : undefined;
102
+ const windows = [...(previous?.windows ?? []), { ...window }].sort((a, b) => a.start - b.start || a.end - b.end);
103
+ const merged: ReadWindow[] = [];
104
+ for (const next of windows) {
105
+ const last = merged[merged.length - 1];
106
+ if (last && next.start <= last.end + 1) last.end = Math.max(last.end, next.end);
107
+ else merged.push({ ...next });
108
+ }
109
+ entry.exposure = { revision, windows: merged.slice(0, WINDOW_LIMIT),
110
+ windowsOmitted: !!previous?.windowsOmitted || merged.length > WINDOW_LIMIT };
111
+ entry.status = "seen";
112
+ };
113
+
114
+ /** Snapshot all retained entries, pending first. Presentation never consumes them. */
115
+ export const reviewReport = (memory?: ReviewMemory) => ({
116
+ ...counts(memory),
117
+ epoch: memory?.epoch,
118
+ previous: memory?.previous ? { ...memory.previous } : undefined,
119
+ entries: [...(memory?.entries ?? [])]
120
+ .sort((a, b) => Number(a[1].status === "seen") - Number(b[1].status === "seen") || bySalience(a, b))
121
+ .map(([file, entry]) => ({ ...entry, file, reasons: [...entry.reasons],
122
+ exposure: entry.exposure ? { ...entry.exposure, windows: entry.exposure.windows.map((w) => ({ ...w })) } : undefined })),
123
+ });
124
+
125
+ export const reviewTrailer = (report: ReturnType<typeof reviewReport>): string => {
126
+ if (!report.total && !report.previous && !report.evicted && !report.omitted) return "";
127
+ const files = report.entries.filter((entry) => entry.status !== "seen").slice(0, 3).map((entry) => entry.file);
128
+ const notes = [
129
+ files.length ? files.join(", ") : "",
130
+ report.evicted ? `${report.evicted} entries evicted` : "",
131
+ report.omitted ? `${report.omitted} candidates not retained` : "",
132
+ report.previous ? `prior epoch cleared: ${report.previous.total} entries (${report.previous.unseen} unread, ${report.previous.stale} stale)` : "",
133
+ ].filter(Boolean);
134
+ return `review memory · ${report.unseen} unread · ${report.stale} stale · ${report.seen} seen (windows only)` +
135
+ (notes.length ? ` · ${notes.join(" · ")}` : "");
136
+ };
@@ -6,6 +6,7 @@
6
6
  import { isAbsolute, relative, resolve, sep } from "node:path";
7
7
  import { ROOT_CACHE_LIMIT } from "./asyncutil.js";
8
8
  import type { NodeKind } from "./types.js";
9
+ import { observeReviewRevision, type ReviewMemory } from "./review.js";
9
10
 
10
11
  interface FocusScope {
11
12
  path?: string;
@@ -27,16 +28,8 @@ export interface FoveaSession {
27
28
  syncScopes: Set<string>;
28
29
  tk: Float64Array[];
29
30
  tkKey: string;
30
- /** Persistent review obligations for the active change epoch. */
31
- obligationEpoch?: {
32
- epochId: string;
33
- ledger: Map<string, {
34
- mass: number;
35
- reasons: string[];
36
- generation: number;
37
- status: "unresolved" | "inspected" | "changed" | "verified";
38
- }>;
39
- };
31
+ /** Bounded, advisory exposure history; independent of current salience. */
32
+ reviewMemory?: ReviewMemory;
40
33
  }
41
34
 
42
35
  export const FOCUS_T0 = 2;
@@ -65,7 +58,11 @@ export const getSession = (root: string): FoveaSession => {
65
58
  tkKey: "",
66
59
  };
67
60
  sessions.set(root, s);
68
- while (sessions.size > ROOT_CACHE_LIMIT) sessions.delete(sessions.keys().next().value!);
61
+ while (sessions.size > ROOT_CACHE_LIMIT) {
62
+ const oldest = sessions.keys().next().value!;
63
+ sessions.get(oldest)?.reviewMemory?.entries.clear();
64
+ sessions.delete(oldest);
65
+ }
69
66
  return s;
70
67
  };
71
68
 
@@ -96,7 +93,13 @@ export const observeSessionPaths = (root: string, paths: readonly string[]): str
96
93
  return [...session.syncScopes].sort();
97
94
  };
98
95
 
99
- /** Drop index-addressed focus state while preserving attention and obligations. */
96
+ /** Reconcile content drift regardless of mutation path, without enrolling a session. */
97
+ export const refreshSessionReviews = (root: string, revisionFor: (file: string) => string | undefined): void => {
98
+ const memory = sessions.get(root)?.reviewMemory;
99
+ for (const file of memory?.entries.keys() ?? []) observeReviewRevision(memory, file, revisionFor(file));
100
+ };
101
+
102
+ /** Drop index-addressed focus state while preserving attention and review memory. */
100
103
  export const clearSessionFocus = (session: FoveaSession): void => {
101
104
  session.t = FOCUS_T0;
102
105
  session.seeds = [];
@@ -111,11 +114,10 @@ export const clearSessionFocus = (session: FoveaSession): void => {
111
114
 
112
115
  // `/new` and friends: same repo, fresh eyes.
113
116
  export const resetSessions = (): void => {
114
- // Clear ledgers before dropping sessions so callers retaining an old session
115
- // cannot keep querying obligations across a conversation reset.
117
+ // Retained captures cannot acknowledge reads across a conversation reset.
116
118
  for (const session of sessions.values()) {
117
- session.obligationEpoch?.ledger.clear();
118
- delete session.obligationEpoch;
119
+ session.reviewMemory?.entries.clear();
120
+ delete session.reviewMemory;
119
121
  }
120
122
  // A fresh conversation cannot reuse disclosure or Chebyshev vectors; drop
121
123
  // the entries outright so large Float64Array stacks become collectible.
package/src/core/state.ts CHANGED
@@ -20,6 +20,7 @@ import { buildCsr, type Csr } from "./heat.js";
20
20
  import type { JoinIndex } from "./join.js";
21
21
  import { coChangeHistory, type CoChangeHistory } from "./cochange.js";
22
22
  import type { EdgeEvidence, Graph } from "./types.js";
23
+ import { refreshSessionReviews } from "./session.js";
23
24
 
24
25
  export interface RepoState {
25
26
  root: string;
@@ -385,7 +386,7 @@ export const ensureState = (root: string, opts: { hints?: string[]; force?: bool
385
386
  const pending = inflight.get(root);
386
387
  if (pending) return pending;
387
388
  const warm = touch(root);
388
- const p: Promise<RepoState> = warm
389
+ const p: Promise<RepoState> = (warm
389
390
  ? refreshState(warm, opts.hints, opts.force)
390
391
  : (async () => {
391
392
  const st = await stat(root).catch(() => undefined);
@@ -394,7 +395,12 @@ export const ensureState = (root: string, opts: { hints?: string[]; force?: bool
394
395
  states.set(root, state);
395
396
  evictLru();
396
397
  return state;
397
- })();
398
+ })()).then((state) => {
399
+ // Even comment-only and out-of-band changes stale prior source exposure;
400
+ // this does not alter sync's semantic surprise gate or trigger turns.
401
+ refreshSessionReviews(root, (file) => state.facts[file]?.sha1 ?? state.store.failedSha.get(file));
402
+ return state;
403
+ });
398
404
  inflight.set(root, p);
399
405
  const clear = (): void => {
400
406
  if (inflight.get(root) === p) inflight.delete(root);
package/src/index.ts CHANGED
@@ -13,7 +13,9 @@ 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 { observeReviewRevision } from "./core/review.js";
18
+ import { captureReviewRead, finishReviewRead } from "./core/review-read.js";
17
19
  import { captureMutation, finishMutation, type MutationCapture } from "./core/provenance.js";
18
20
  import { resetSyncBaselines, sync, syncBaselineStore, warmSync } from "./core/sync.js";
19
21
  import type { NodeKind } from "./core/types.js";
@@ -302,6 +304,9 @@ export default function fovea(pi: ExtensionAPI) {
302
304
  const WARM_DEBOUNCE_MS = 250;
303
305
  const warmTimers = new Map<string, ReturnType<typeof setTimeout>>();
304
306
  const pendingMutations = new Map<string, MutationCapture>();
307
+ // Capture a bounded source snapshot; only matching successful returned
308
+ // windows become exposure. Reads are not verification or completion.
309
+ const pendingReads = new Map<string, NonNullable<Awaited<ReturnType<typeof captureReviewRead>>>>();
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;
@@ -330,10 +336,17 @@ export default function fovea(pi: ExtensionAPI) {
330
336
  turnFiles = [];
331
337
  });
332
338
  pi.on("tool_execution_start", async (event, ctx) => {
333
- const args = event.args as { path?: unknown };
339
+ const args = event.args as { path?: unknown; offset?: unknown; limit?: 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") {
345
+ const epoch = lifecycleEpoch;
346
+ const capture = await captureReviewRead(getSession(owner.root).reviewMemory, owner.root, owner.path, args);
347
+ if (capture && epoch === lifecycleEpoch) pendingReads.set(event.toolCallId, capture);
348
+ }
349
+ }
337
350
  }
338
351
  if (event.toolName !== "edit" && event.toolName !== "write") return;
339
352
  if (typeof args.path !== "string") return;
@@ -346,10 +359,22 @@ export default function fovea(pi: ExtensionAPI) {
346
359
  // Warm once the file is actually on disk (tool_execution_start fires during
347
360
  // preflight, before the write lands); the debounce also coalesces bursts.
348
361
  pi.on("tool_execution_end", async (event, ctx) => {
362
+ const read = pendingReads.get(event.toolCallId);
363
+ if (read) {
364
+ pendingReads.delete(event.toolCallId);
365
+ if (!event.isError) await finishReviewRead(read, event.result);
366
+ }
349
367
  if (event.toolName !== "edit" && event.toolName !== "write") return;
350
368
  const capture = pendingMutations.get(event.toolCallId);
351
369
  pendingMutations.delete(event.toolCallId);
352
370
  if (!event.isError && capture) {
371
+ // No-op writes preserve exposure. Other paths are reconciled against
372
+ // content hashes on refresh, independently of semantic turn steering.
373
+ const memory = getSession(capture.root).reviewMemory;
374
+ if (memory?.entries.has(capture.file)) {
375
+ const after = await captureMutation(capture.root, capture.file);
376
+ if (capture.beforeSha !== after?.beforeSha) observeReviewRevision(memory, capture.file, after?.beforeSha);
377
+ }
353
378
  await finishMutation(capture, ctx.sessionManager.getSessionId(), event.toolCallId).catch(() => false);
354
379
  }
355
380
  if (capture) warmAfterEdit(capture.root, targetConfig(capture.root, ctx));
@@ -513,7 +538,7 @@ export default function fovea(pi: ExtensionAPI) {
513
538
  name: "fovea_impact",
514
539
  label: "Fovea Impact",
515
540
  description:
516
- "Predict review order from changed files, symbols, or a PR base. Returns warmed files with causal channels, deterministic edge evidence paths, explicit input coverage gaps, co-change history, and obligations.",
541
+ "Predict review order from changed files, symbols, or a PR base. Returns warmed files with causal channels, deterministic edge evidence paths, explicit input coverage gaps, co-change history, and advisory unread/stale review memory (not completion evidence).",
517
542
  promptSnippet: "Predict the likely review surface of a change",
518
543
  promptGuidelines: ["Use fovea_impact before broad or risky edits and when checking the blast radius of completed changes."],
519
544
  parameters: Type.Object({
@@ -568,10 +593,11 @@ export default function fovea(pi: ExtensionAPI) {
568
593
  for (const timer of warmTimers.values()) clearTimeout(timer);
569
594
  warmTimers.clear();
570
595
  pendingMutations.clear();
596
+ pendingReads.clear();
571
597
  turnFiles = [];
572
598
  resetSessions();
573
599
  resetSyncBaselines();
574
- ctx.ui.notify("Fovea focus history and sync baseline reset.", "info");
600
+ ctx.ui.notify("Fovea focus history, review memory, and sync baseline cleared.", "info");
575
601
  return;
576
602
  }
577
603
  if (sub === "settings") {
@@ -1,163 +0,0 @@
1
- // Persistent review obligations are deliberately separate from sync's
2
- // wall-clock-decayed novelty heat. This module has no timers or IO: callers
3
- // drive every state transition explicitly on the active Fovea session.
4
-
5
- import type { FoveaSession } from "./session.js";
6
-
7
- type ObligationEpoch = NonNullable<FoveaSession["obligationEpoch"]>;
8
- type ObligationEntry = ObligationEpoch["ledger"] extends Map<string, infer Entry> ? Entry : never;
9
-
10
- type ResidualObligation = ObligationEntry & { file: string };
11
- type EpochStats = {
12
- total: number;
13
- unresolved: number;
14
- inspected: number;
15
- changed: number;
16
- verified: number;
17
- mass: number;
18
- };
19
-
20
- const LEDGER_LIMIT = 512;
21
-
22
- const seedFingerprint = (seedFiles: Iterable<string>): string => {
23
- // Canonicalizing a copy freezes the epoch's seed baseline without retaining
24
- // a second mutable collection alongside the prescribed ledger state.
25
- const files = [...new Set(seedFiles)].sort();
26
- let hash = 0x811c9dc5;
27
- for (const file of files) {
28
- for (let i = 0; i < file.length; i++) {
29
- hash = Math.imul(hash ^ file.charCodeAt(i), 0x01000193);
30
- }
31
- hash = Math.imul(hash ^ 0, 0x01000193);
32
- }
33
- return (hash >>> 0).toString(36);
34
- };
35
-
36
- const nextEpochOrdinal = (session: FoveaSession): number => {
37
- const encoded = session.obligationEpoch?.epochId.match(/^obligation-([0-9a-z]+)-/)?.[1];
38
- const previous = encoded === undefined ? 0 : Number.parseInt(encoded, 36);
39
- return Number.isSafeInteger(previous) && previous >= 0 ? previous + 1 : 1;
40
- };
41
-
42
- /** Start a fresh obligation epoch and discard every obligation from the old one. */
43
- export const openEpoch = (session: FoveaSession, seedFiles: Iterable<string>): ObligationEpoch => {
44
- const ordinal = nextEpochOrdinal(session);
45
- session.obligationEpoch?.ledger.clear();
46
- const epoch: ObligationEpoch = {
47
- epochId: `obligation-${ordinal.toString(36)}-${seedFingerprint(seedFiles)}`,
48
- ledger: new Map(),
49
- };
50
- session.obligationEpoch = epoch;
51
- return epoch;
52
- };
53
-
54
- const activeEpoch = (session: FoveaSession): ObligationEpoch =>
55
- session.obligationEpoch ?? openEpoch(session, []);
56
-
57
- const enforceBound = (ledger: ObligationEpoch["ledger"]): void => {
58
- if (ledger.size <= LEDGER_LIMIT) return;
59
- // Keep the strongest obligations. Path order makes equal-mass eviction
60
- // deterministic; lexically earlier paths survive a tie.
61
- const weakest = [...ledger.entries()].sort(
62
- (a, b) => a[1].mass - b[1].mass || b[0].localeCompare(a[0]),
63
- );
64
- for (let i = 0; i < weakest.length - LEDGER_LIMIT; i++) {
65
- ledger.delete(weakest[i]![0]);
66
- }
67
- };
68
-
69
- /** Add durable file-level warmth to the current epoch. Existing state is preserved. */
70
- export const mergeWarmed = (
71
- session: FoveaSession,
72
- fileMass: ReadonlyMap<string, number>,
73
- reason: string,
74
- ): void => {
75
- const ledger = activeEpoch(session).ledger;
76
- for (const [file, mass] of fileMass) {
77
- // Heat mass is non-negative. Ignoring invalid input keeps a malformed
78
- // producer from reducing or poisoning an already-durable obligation.
79
- if (!Number.isFinite(mass) || mass <= 0) continue;
80
- const entry = ledger.get(file);
81
- if (entry) {
82
- const sum = entry.mass + mass;
83
- entry.mass = Number.isFinite(sum) ? sum : Number.MAX_VALUE;
84
- if (reason && !entry.reasons.includes(reason)) entry.reasons.push(reason);
85
- continue;
86
- }
87
- ledger.set(file, {
88
- mass,
89
- reasons: reason ? [reason] : [],
90
- generation: 0,
91
- status: "unresolved",
92
- });
93
- }
94
- enforceBound(ledger);
95
- };
96
-
97
- /** Record that the latest generation was inspected; verification remains explicit. */
98
- export const markRead = (session: FoveaSession, files: Iterable<string>): void => {
99
- const ledger = session.obligationEpoch?.ledger;
100
- if (!ledger) return;
101
- for (const file of files) {
102
- const entry = ledger.get(file);
103
- if (entry?.status === "unresolved" || entry?.status === "changed") {
104
- entry.status = "inspected";
105
- }
106
- }
107
- };
108
-
109
- /** Record a new file generation. A subsequent read can inspect this generation again. */
110
- export const markEdited = (session: FoveaSession, files: Iterable<string>): void => {
111
- const ledger = session.obligationEpoch?.ledger;
112
- if (!ledger) return;
113
- for (const file of files) {
114
- const entry = ledger.get(file);
115
- if (!entry) continue;
116
- entry.generation++;
117
- entry.status = "changed";
118
- }
119
- };
120
-
121
- /** Mark obligations verified without deleting their epoch history. */
122
- export const markVerified = (session: FoveaSession, files: Iterable<string>): void => {
123
- const ledger = session.obligationEpoch?.ledger;
124
- if (!ledger) return;
125
- for (const file of files) {
126
- const entry = ledger.get(file);
127
- if (entry) entry.status = "verified";
128
- }
129
- };
130
-
131
- /** Return a non-consuming, strongest-first snapshot for the checklist renderer. */
132
- export const residual = (session: FoveaSession): ResidualObligation[] => {
133
- const ledger = session.obligationEpoch?.ledger;
134
- if (!ledger) return [];
135
- return [...ledger.entries()]
136
- .filter(([, entry]) => entry.status === "unresolved")
137
- .sort((a, b) => b[1].mass - a[1].mass || a[0].localeCompare(b[0]))
138
- .map(([file, entry]) => ({
139
- file,
140
- mass: entry.mass,
141
- reasons: [...entry.reasons],
142
- generation: entry.generation,
143
- status: "unresolved",
144
- }));
145
- };
146
-
147
- /** Summarize all retained entries and their total, non-decaying mass. */
148
- export const epochStats = (session: FoveaSession): EpochStats => {
149
- const stats: EpochStats = {
150
- total: 0,
151
- unresolved: 0,
152
- inspected: 0,
153
- changed: 0,
154
- verified: 0,
155
- mass: 0,
156
- };
157
- for (const entry of session.obligationEpoch?.ledger.values() ?? []) {
158
- stats.total++;
159
- stats.mass += entry.mass;
160
- stats[entry.status]++;
161
- }
162
- return stats;
163
- };