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 +24 -42
- package/dist/cli.mjs +126 -109
- package/package.json +1 -1
- package/skills/pi-fovea/SKILL.md +1 -1
- package/src/core/ops.ts +29 -18
- package/src/core/render.ts +8 -3
- package/src/core/review-read.ts +66 -0
- package/src/core/review.ts +136 -0
- package/src/core/session.ts +18 -16
- package/src/core/state.ts +8 -2
- package/src/index.ts +31 -5
- package/src/core/obligations.ts +0 -163
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
|
|
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
|
|
307
|
-
|
|
308
|
-
`fovea_impact`
|
|
309
|
-
|
|
310
|
-
Heat finds what matters now. Seeds come from the diff. Hunk parsing maps each
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
380
|
+
const counts2 = new Uint32Array(n);
|
|
381
381
|
for (const [a, b] of pairs) {
|
|
382
|
-
|
|
383
|
-
|
|
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] +
|
|
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)
|
|
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:
|
|
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
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
5773
|
-
|
|
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
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-
|
|
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 {
|
|
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:
|
|
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
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
1077
|
-
|
|
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
|
};
|
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),
|
|
@@ -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
|
+
};
|
package/src/core/session.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
|
|
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)
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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.
|
|
118
|
-
delete session.
|
|
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)
|
|
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
|
|
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
|
|
600
|
+
ctx.ui.notify("Fovea focus history, review memory, and sync baseline cleared.", "info");
|
|
575
601
|
return;
|
|
576
602
|
}
|
|
577
603
|
if (sub === "settings") {
|
package/src/core/obligations.ts
DELETED
|
@@ -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
|
-
};
|