dsh-engram 0.7.4 → 0.8.7

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.
Files changed (50) hide show
  1. package/README.md +100 -6
  2. package/README.zh.md +7 -2
  3. package/docs/CONTEXT-GC-GUIDE.zh.md +15 -1
  4. package/docs/G6-quality-ab.zh.md +540 -0
  5. package/docs/GATES.zh.md +41 -0
  6. package/docs/PROPOSAL-evolution.zh.md +12 -0
  7. package/docs/RELEASE-NOTES-v0.7.10.md +49 -0
  8. package/docs/RELEASE-NOTES-v0.7.11.md +31 -0
  9. package/docs/RELEASE-NOTES-v0.7.12.md +22 -0
  10. package/docs/RELEASE-NOTES-v0.7.13.md +41 -0
  11. package/docs/RELEASE-NOTES-v0.7.14.md +37 -0
  12. package/docs/RELEASE-NOTES-v0.7.15.md +36 -0
  13. package/docs/RELEASE-NOTES-v0.7.16.md +42 -0
  14. package/docs/RELEASE-NOTES-v0.7.5.md +134 -0
  15. package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
  16. package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
  17. package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
  18. package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
  19. package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
  20. package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
  21. package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
  22. package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
  23. package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
  24. package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
  25. package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
  26. package/docs/RELEASE-NOTES-v0.8.7.md +69 -0
  27. package/eval/gate-audit.mjs +538 -0
  28. package/lib/api.js +70 -0
  29. package/lib/approvals.js +84 -0
  30. package/lib/boundary-prune.js +71 -5
  31. package/lib/capsule.js +134 -0
  32. package/lib/capture.js +83 -5
  33. package/lib/client.js +66 -0
  34. package/lib/compaction.js +4 -11
  35. package/lib/context-gc.js +63 -2
  36. package/lib/index-block.js +79 -4
  37. package/lib/index.js +105 -18
  38. package/lib/ledger.js +200 -10
  39. package/lib/pack-telemetry.js +10 -3
  40. package/lib/prune.js +89 -0
  41. package/lib/push-legs.js +121 -0
  42. package/lib/session-events.js +44 -2
  43. package/lib/skill-export.js +289 -0
  44. package/lib/status-insights.js +5 -0
  45. package/lib/status.js +138 -0
  46. package/lib/store.js +34 -0
  47. package/lib/usage.js +32 -3
  48. package/lib/web-provision.js +59 -14
  49. package/package.json +15 -8
  50. package/scripts/dsh-engram.mjs +247 -5
package/lib/context-gc.js CHANGED
@@ -33,6 +33,7 @@
33
33
 
34
34
  import { basename } from "node:path";
35
35
  import { slugId } from "./util.js";
36
+ import { recordGcAttempt } from "./status.js";
36
37
 
37
38
  /** Memory ids are uuids; the [ENGRAM] index shows `#` + first 8 alnum chars. */
38
39
  const MEMORY_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
@@ -608,6 +609,43 @@ export async function summarizeUnprovenanced(messages, { enabled = true, maxChar
608
609
  * Build the Context GC replacement summary (pointer block + narrative). This
609
610
  * is the text that lands inside DSH's `<compacted-summary>` checkpoint node.
610
611
  */
612
+ /** The line every Context GC checkpoint carries — proof of authorship when a
613
+ * committed summary is read back from the session log. */
614
+ export const CONTEXT_GC_HEADING = "## Context GC — evicted detail is RE-FETCHABLE (do not re-derive)";
615
+ export const CONTEXT_GC_FOOTER = "_Context GC: detail above is re-fetchable via the listed tools. Prefer re-fetching over re-deriving._";
616
+
617
+ /**
618
+ * Turn a committed `compaction/summary` event into a Context Ledger credit, or
619
+ * `null` when engram did not author it.
620
+ *
621
+ * The credit has to come from the COMMITTED record, not from inside
622
+ * `summarize()`: the harness may reject a checkpoint (framed ≥ shadowed) or fall
623
+ * back to its own summarizer, and a saving that never reached the session is not
624
+ * a saving. `shadowedTokenCount` is the span that stopped replaying, the summary
625
+ * text is what replaced it.
626
+ */
627
+ export function committedGcSaving(event, charsPerToken = 4) {
628
+ if (event?.type !== "compaction/summary") return null;
629
+ // DSH publishes committed events as { type, seq, time, data: { summary,
630
+ // shadowedTokenCount, … } }. Reading the payload off the event root made this
631
+ // matcher return null for EVERY real checkpoint — 82 GC-authored summaries on
632
+ // this machine, none credited — while the unit test kept passing because it
633
+ // built a flat synthetic event. Accept both shapes; prefer the real one.
634
+ const payload = event?.data !== null && typeof event?.data === "object" ? event.data : event;
635
+ const text = Array.isArray(payload?.summary)
636
+ ? payload.summary.map((part) => (typeof part?.text === "string" ? part.text : "")).join("")
637
+ : "";
638
+ // Either end identifies the checkpoint: the heading survives a trim (it is
639
+ // the head), the footer is the cheaper full-text check. A checkpoint trimmed
640
+ // to fit keeps the heading but may lose the footer, and it still committed.
641
+ const authored = text.includes(CONTEXT_GC_FOOTER) || [...text].slice(0, 200).join("").includes(CONTEXT_GC_HEADING);
642
+ if (!authored) return null;
643
+ const shadowed = Number(payload?.shadowedTokenCount ?? event?.shadowedTokenCount);
644
+ if (!Number.isFinite(shadowed) || shadowed <= 0) return null;
645
+ const charsSaved = shadowed * charsPerToken - [...text].length;
646
+ return charsSaved > 0 ? { charsSaved, shadowedTokens: shadowed, summaryChars: [...text].length } : null;
647
+ }
648
+
611
649
  export function buildContextGcSummary(report, context, { turns, narrative }) {
612
650
  const label = context?.label ?? "this workspace";
613
651
  const pointer = buildPointerSummary(report, context, { turns, label });
@@ -617,7 +655,7 @@ export function buildContextGcSummary(report, context, { turns, narrative }) {
617
655
  narrative.text,
618
656
  "",
619
657
  "---",
620
- "_Context GC: detail above is re-fetchable via the listed tools. Prefer re-fetching over re-deriving._",
658
+ CONTEXT_GC_FOOTER,
621
659
  ];
622
660
  return parts.join("\n");
623
661
  }
@@ -732,15 +770,37 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
732
770
  constructor() {
733
771
  super(ctx, engineConfig);
734
772
  this.gcDeps = deps;
773
+ /** Last credit computed by `_contextGc`, surfaced in the attempt log. */
774
+ this.gcLastCredit = void 0;
735
775
  }
736
776
 
737
777
  /** The one seam we take over: produce the checkpoint summary. */
738
778
  async summarize(input, agent, signal) {
779
+ const startedAt = Date.now();
780
+ const workspace = agent?.session?.header?.cwd;
739
781
  try {
740
- return await this._contextGc(input, agent, signal);
782
+ const result = await this._contextGc(input, agent, signal);
783
+ // Evidence, not vibes: the ledger's credit side only moves when THIS
784
+ // path runs, so every attempt is recorded where it can be inspected
785
+ // offline (a silent fallback used to look identical to a working GC).
786
+ // The head + length identify the checkpoint: if the committed
787
+ // `compaction/summary` in the session log does not start like this, the
788
+ // harness used someone else's summary and nothing was saved.
789
+ const text = Array.isArray(result?.summary) ? result.summary.map((b) => (typeof b?.text === "string" ? b.text : "")).join("") : "";
790
+ await recordGcAttempt({
791
+ plane: "compaction",
792
+ ok: true,
793
+ ms: Date.now() - startedAt,
794
+ workspace,
795
+ charsSaved: this.gcLastCredit?.charsSaved ?? 0,
796
+ summaryChars: [...text].length,
797
+ head: [...text].slice(0, 60).join(""),
798
+ });
799
+ return result;
741
800
  } catch (error) {
742
801
  const logger = this.ctx?.logger;
743
802
  const message = error instanceof Error ? error.message : String(error);
803
+ await recordGcAttempt({ plane: "compaction", ok: false, ms: Date.now() - startedAt, workspace, error: message });
744
804
  if (typeof logger?.warn === "function") logger.warn(`engram context-gc failed (${message}); falling back to default compaction`);
745
805
  if (typeof this.gcDeps.defaultSummarize === "function") {
746
806
  return this.gcDeps.defaultSummarize(input, agent, signal);
@@ -804,6 +864,7 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
804
864
  const summaryChars = [...summaryText].length;
805
865
  const charsSaved = spanChars - summaryChars;
806
866
  if (charsSaved > 0) {
867
+ this.gcLastCredit = { charsSaved, spanChars, summaryChars };
807
868
  this.gcDeps.onCompacted({ session: agent?.session, workspace: ws, charsSaved, spanChars, summaryChars });
808
869
  }
809
870
  }
@@ -13,6 +13,7 @@ import { label, shortId, fmtDate, escapeLt, truncate, byRecency } from "./util.j
13
13
  import { buildEntityPage, renderEntityCard } from "./entity-page.js";
14
14
  import { memoryIsStale } from "./anchor.js";
15
15
  import { readSessionEvents } from "./session-events.js";
16
+ import fs from "node:fs";
16
17
 
17
18
  /** Deterministic index line prefix per memory kind. */
18
19
  export function prefixFor(kind) {
@@ -42,9 +43,77 @@ function isGitPlumbingEcho(m) {
42
43
  return /(^|\s)(cd\s|\&\&|;|\|)/.test(text);
43
44
  }
44
45
 
45
- /** A procedure that has been used (hit) enough times to count as proven. */
46
+ /**
47
+ * Has the file this memory is anchored to changed since the memory was written?
48
+ *
49
+ * The reverse G6 experiment (docs/G6-quality-ab.zh.md §9) is why this exists:
50
+ * with a stale convention in memory and a contradicting docstring in the code,
51
+ * the agent followed memory on 1 of 6 tasks because nothing marked the memory as
52
+ * old. A memory that names a file can carry that check for free — one `stat`,
53
+ * and the injected line can say "the file moved on".
54
+ *
55
+ * Returns "" when there is nothing to say (no anchor, file unchanged, or the
56
+ * stat fails); a short marker otherwise. Never throws: injection must not break
57
+ * because a path is unreadable.
58
+ */
59
+ export function fileDrift(m, nowMs = Date.now(), stat = void 0) {
60
+ const file = typeof m?.filePath === "string" ? m.filePath : "";
61
+ if (file.length === 0) return "";
62
+ const written = m?.createdAt ?? m?.updatedAt;
63
+ if (written === null || written === void 0) return "";
64
+ const writtenMs = typeof written === "number" ? written : Date.parse(String(written));
65
+ if (!Number.isFinite(writtenMs) || writtenMs > nowMs) return "";
66
+ const doStat = stat ?? ((target) => fs.statSync(target));
67
+ let statInfo;
68
+ try {
69
+ statInfo = doStat(file);
70
+ } catch {
71
+ return "⚠file-missing";
72
+ }
73
+ const mtimeMs = statInfo?.mtimeMs ?? 0;
74
+ if (!Number.isFinite(mtimeMs) || mtimeMs <= writtenMs) return "";
75
+ // Only a coarse signal: "the anchored file was edited after this was written".
76
+ // The model can then check the file instead of trusting the note.
77
+ return "⚠file-changed";
78
+ }
79
+
80
+ /**
81
+ * v0.8-A promotion rules for procedures.
82
+ *
83
+ * A procedure earns an index line only when it is still true:
84
+ * - verified at least `procedureMinEvidence` (3) times,
85
+ * - verified within `procedureMaxAgeDays` (14),
86
+ * - and not failed since its last verification.
87
+ * A manual procedure with no evidence fields keeps the older hit-based path
88
+ * (`hits >= promoteHits`), so hand-written procedures are not silently demoted.
89
+ */
90
+ export function procedureInvalidated(m) {
91
+ if ((m?.kind ?? "") !== "procedure") return false;
92
+ if (m?.lastFailedAt === null || m?.lastFailedAt === void 0) return false;
93
+ return m.lastVerifiedAt === null || m.lastVerifiedAt === void 0 || m.lastFailedAt >= m.lastVerifiedAt;
94
+ }
95
+
46
96
  export function isProvenProcedure(m, config) {
47
- return (m?.kind ?? "") === "procedure" && (m?.hits ?? 0) >= (config?.promoteHits ?? 3);
97
+ if ((m?.kind ?? "") !== "procedure") return false;
98
+ if (procedureInvalidated(m)) return false;
99
+ const nowMs = config?.nowMs ?? Date.now();
100
+ const minEvidence = config?.procedureMinEvidence ?? 3;
101
+ const maxAgeMs = (config?.procedureMaxAgeDays ?? 14) * 24 * 60 * 60 * 1000;
102
+ if ((m.evidenceCount ?? 0) > 0) {
103
+ const verified = m.lastVerifiedAt ?? 0;
104
+ return (m.evidenceCount ?? 0) >= minEvidence && verified > 0 && nowMs - verified <= maxAgeMs;
105
+ }
106
+ return (m?.hits ?? 0) >= (config?.promoteHits ?? 3);
107
+ }
108
+
109
+ /**
110
+ * An auto-captured `fixed:` row is a hypothesis until it has evidence — it
111
+ * must not earn an index line on the strength of one success.
112
+ */
113
+ export function isUnprovenAutoProcedure(m, config) {
114
+ if ((m?.kind ?? "") !== "procedure") return false;
115
+ if (!Array.isArray(m.tags) || !m.tags.includes("auto-captured")) return false;
116
+ return !isProvenProcedure(m, config);
48
117
  }
49
118
 
50
119
  /**
@@ -67,6 +136,10 @@ export function selectIndexMemories(store, workspace, config) {
67
136
  // A memory whose file source changed/vanished is no longer current truth
68
137
  // for an auto-injected index line (still re-fetchable via engram_detail).
69
138
  .filter((m) => !memoryIsStale(m))
139
+ // v0.8-A: a just-invalidated procedure is worse than none — the next agent
140
+ // would copy it verbatim. Unproven auto-captures stay out until they earn it.
141
+ .filter((m) => !procedureInvalidated(m))
142
+ .filter((m) => !isUnprovenAutoProcedure(m, config))
70
143
  .filter(
71
144
  (m) =>
72
145
  (isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
@@ -99,7 +172,8 @@ function memoryLine(m, config) {
99
172
  const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
100
173
  const date = fmtDate(m.createdAt ?? m.updatedAt);
101
174
  const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), INDEX_LINE_CHARS);
102
- return `[${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}`;
175
+ const drift = fileDrift(m, config?.nowMs ?? Date.now(), config?.stat);
176
+ return `[${mark}] ${date} ${escapeLt(text)}${drift === "" ? "" : ` ${drift}`} #${shortId(m.id)}`;
103
177
  }
104
178
 
105
179
  function taskStateLine(t) {
@@ -274,7 +348,8 @@ export function renderStartRecall(store, workspace, config = {}, opts = {}) {
274
348
  const truncated = [...full].length > 140;
275
349
  const text = truncate(full, 140);
276
350
  if (truncated && truncatedId === null) truncatedId = shortId(m.id);
277
- lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${truncated ? " …" : ""}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
351
+ const drift = fileDrift(m, config?.nowMs ?? Date.now(), config?.stat);
352
+ lines.push(`- [${mark}] ${date} ${escapeLt(text)}${drift === "" ? "" : ` ${drift}`} #${shortId(m.id)}${truncated ? " …" : ""}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
278
353
  }
279
354
  const drill = truncatedId !== null
280
355
  ? `context: use above · #${truncatedId} truncated — engram_detail ${truncatedId} (full) · engram_recall <query>`
package/lib/index.js CHANGED
@@ -27,19 +27,20 @@ import { installPreferenceCapture } from "./preference.js";
27
27
  import { boundaryPruneAvailability, installBoundaryPrune } from "./boundary-prune.js";
28
28
  import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } from "./result-pack.js";
29
29
  import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
30
- import { createLedger, estimatePrefixChars } from "./ledger.js";
30
+ import { CHARS_PER_TOKEN, createLedger, estimatePrefixChars } from "./ledger.js";
31
31
  import { createJit } from "./jit.js";
32
32
  import { buildEpisode } from "./episode.js";
33
33
  import { collectStaleAnchors } from "./anchor.js";
34
34
  import { renderIndex, renderStartRecall, renderResumeBrief, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
35
- import { sessionEventsSource, sessionSeq } from "./session-events.js";
35
+ import { describeSessionSurface, sessionEventsSource, sessionSeq } from "./session-events.js";
36
+ import { noteSessionEventsSource } from "./push-legs.js";
36
37
  import { makeTriggerRecorder } from "./trigger.js";
37
38
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
38
39
  import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
39
40
  import { makeEngramRoutes } from "./api.js";
40
- import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
41
+ import { collectPointerContext, committedGcSaving, mountCompactionEngine } from "./context-gc.js";
41
42
  import { provisionWebCompaction } from "./web-provision.js";
42
- import { writeContextGcStatus } from "./status.js";
43
+ import { writeContextGcStatus, recordPruneAttempt } from "./status.js";
43
44
  import { workspaceKey as wk } from "./util.js";
44
45
 
45
46
  /** Stable cordis plugin name. */
@@ -81,11 +82,23 @@ const DEFAULTS = {
81
82
  // memories that outrank the [ENGRAM] index.
82
83
  autoCapturePreferences: true,
83
84
  preferenceCapturePerSession: 8,
85
+ // v0.8-C (advisory): a subagent report above this many chars is distilled
86
+ // into a durable `handoff` capsule. The parent still receives the full text —
87
+ // DSH's tools/result is observer-only (no mutation channel) — so this buys
88
+ // durability and a measurable saving, not a smaller prompt. See lib/capsule.js.
89
+ capsuleMinSourceChars: 1500,
90
+ capsuleMaxChars: 1800,
84
91
  // [ENGRAM] index budget (token discipline)
85
92
  indexMaxLines: 12,
86
93
  indexMaxChars: 700,
87
94
  minIndexSignal: 0.4,
88
95
  promoteHits: 3,
96
+ // v0.8-A evidence-based procedure promotion: a `fixed:` procedure reaches the
97
+ // [ENGRAM] index only after this many verified successes, and only while the
98
+ // last verification is younger than procedureMaxAgeDays. A later failure
99
+ // invalidates it immediately (see store.recordProcedureOutcome).
100
+ procedureMinEvidence: 3,
101
+ procedureMaxAgeDays: 14,
89
102
  // retention / caps
90
103
  expireDays: 180,
91
104
  maxMemoriesPerWorkspace: 2000,
@@ -186,9 +199,13 @@ const DEFAULTS = {
186
199
  // (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
187
200
  // todo-progress boundaries cover 79% of replay exposure; the repayment
188
201
  // gate below lifted cache-aware net savings 12–32% on every threshold.
189
- // Default ON since v0.6 (PROPOSAL-evolution §3.5): `false` is the opt-out;
190
- // the pruner service is feature-detected — absent ⇒ disabled with one log.
191
- boundaryPrune: true,
202
+ //
203
+ // Default OFF since v0.7.11: 15 h of live accounting showed the host prunes
204
+ // over-threshold results by itself (64,200 chars across two sessions with
205
+ // zero engram involvement), while the boundary source never fired in an
206
+ // ordinary 220-request session — it only pays for itself in planning
207
+ // sessions that actually use todos/goals. `true` is the opt-in.
208
+ boundaryPrune: false,
192
209
  // Adaptive off-switch (v0.6-B): the Context Ledger vetoes further fires in a
193
210
  // workspace whose own accounting is net negative after real fires. Evidence
194
211
  // can only turn pruning OFF — never on. `false` = repayment gate only.
@@ -422,6 +439,8 @@ export function apply(ctx, config) {
422
439
 
423
440
  const service = {
424
441
  config: resolved,
442
+ /** Where local (non-store) engram state lives — approvals, telemetry, ledger. */
443
+ engramHome: () => engramHome(),
425
444
  getDomain,
426
445
  openedDomain: () => opened,
427
446
  recorder,
@@ -525,6 +544,8 @@ export function apply(ctx, config) {
525
544
  const sessionCache = new WeakMap();
526
545
  /** One-time-per-process notice: the host's session log is unreadable. */
527
546
  let warnedUnreadableLog = false;
547
+ /** Sessions whose event-log read path was already recorded (push-legs.jsonl). */
548
+ const notedSessionEvents = new WeakSet();
528
549
  const blockFor = (agent) => {
529
550
  if (agent === void 0) return "";
530
551
  const session = agent.session;
@@ -563,6 +584,17 @@ export function apply(ctx, config) {
563
584
  // next assembly retry, so a new conversation never permanently misses its
564
585
  // recalled memory.
565
586
  if (resolved.autoRecallOnStart !== false) {
587
+ // Push-legs telemetry (issue #9): record which read path this session
588
+ // took, once per session object, so `dsh-engram status` can answer
589
+ // "were [RECALL]/episode/resume/todo-sink awake?" without a log capture.
590
+ if (!notedSessionEvents.has(session)) {
591
+ notedSessionEvents.add(session);
592
+ noteSessionEventsSource({
593
+ home: engramHome(),
594
+ source: sessionEventsSource(session, sessionQuery),
595
+ sessionId: session.header?.id ?? session.id,
596
+ });
597
+ }
566
598
  const query = firstUserMessageText(session, sessionQuery);
567
599
  if (query.length === 0) {
568
600
  // Retry on the next assembly ONLY when the log is readable and the
@@ -574,7 +606,7 @@ export function apply(ctx, config) {
574
606
  if (!pendingRecall && !warnedUnreadableLog) {
575
607
  warnedUnreadableLog = true;
576
608
  log?.warn?.(
577
- "engram: this host exposes no readable session event log (no session.events, no ctx.sessionQuery, no snapshotEvents) — [RECALL] / episode / todo-sink stay dormant. Reporting once per session.",
609
+ `engram: this host exposes no readable session event log (no session.events, no ctx.sessionQuery, no snapshotEvents, no eventAt) — [RECALL] / episode / todo-sink stay dormant. Reporting once per session. Session surface: ${describeSessionSurface(session).join(", ") || "(empty)"}`,
578
610
  );
579
611
  }
580
612
  } else {
@@ -668,6 +700,9 @@ export function apply(ctx, config) {
668
700
 
669
701
  ctx.effect(() => {
670
702
  const disposers = [];
703
+ // sessionId → { seqs, at }: the prunes this plugin caused, so the
704
+ // compaction/prune observer can tell engram's savings from the host's.
705
+ const ownPruneSeqs = new Map();
671
706
 
672
707
  // Settings card edits (volatile Config fields) hot-commit into the refs
673
708
  // in place; re-read them so `resolved` reflects new values for new
@@ -703,16 +738,10 @@ export function apply(ctx, config) {
703
738
  return null;
704
739
  }
705
740
  },
706
- // Context Ledger credit: the Context GC engine measures the span it
707
- // replaced with a checkpoint (`span − summary` chars) and reports it
708
- // here; the ledger stops the "savings are always 0" asymmetry.
709
- onCompacted: ({ session, workspace, charsSaved }) => {
710
- try {
711
- ledger?.noteCompaction(session?.id, workspace ?? wk(session?.header?.cwd), { charsSaved });
712
- } catch (error) {
713
- log?.warn?.(`engram ledger compaction credit failed: ${String(error)}`);
714
- }
715
- },
741
+ // Credit is committed-record-driven (see the `compaction/summary`
742
+ // listener below), not summarize()-driven: a checkpoint the harness
743
+ // rejects or overrides saves nothing, and crediting it would make the
744
+ // ledger claim savings that never landed.
716
745
  });
717
746
  disposers.push(() => {
718
747
  // The engine's service registration is fiber-scoped and auto-removed on
@@ -735,6 +764,7 @@ export function apply(ctx, config) {
735
764
  if (!hostSettled || !webSettled) return;
736
765
  if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
737
766
  void writeContextGcStatus({
767
+ pid: process.pid,
738
768
  planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
739
769
  host: service.compactionStatus.host,
740
770
  web: service.compactionStatus.web,
@@ -886,6 +916,45 @@ export function apply(ctx, config) {
886
916
  ledger.dispose(session?.id);
887
917
  });
888
918
  disposers.push(disposeLedgerSession);
919
+ // Compaction credit, committed-record-driven: only a summary that carries
920
+ // the Context GC footer AND actually landed in the session counts. The
921
+ // engine's own measurement (`summarize`) is not evidence — the harness can
922
+ // reject or override it, and then nothing was saved.
923
+ const disposeLedgerCompaction = ctx.on("session/event", (session, event) => {
924
+ const credit = committedGcSaving(event);
925
+ if (credit === null) return;
926
+ ledger.noteCompaction(session?.id, wk(session?.header?.cwd), { charsSaved: credit.charsSaved });
927
+ });
928
+ disposers.push(disposeLedgerCompaction);
929
+ // Prune accounting: DSH prunes over-threshold tool results by itself
930
+ // (mid-step, no boundary involved). Those savings are real but they are
931
+ // NOT engram's, so they land in `hostPrunedChars` — a visible leg that
932
+ // never feeds NetTokens. Prunes we caused are already credited by the
933
+ // boundary hook (matched by seq here and skipped).
934
+ const disposeLedgerPrune = ctx.on("session/event", (session, event) => {
935
+ if (event?.type !== "compaction/prune") return;
936
+ const sid = session?.id;
937
+ const workspace = wk(session?.header?.cwd);
938
+ const tokens = Number(event?.data?.shadowedTokenCount);
939
+ if (!Number.isFinite(tokens) || tokens <= 0) return;
940
+ const shadowed = Array.isArray(event?.data?.shadowedSeqs) ? event.data.shadowedSeqs : [];
941
+ const own = typeof sid === "string" ? ownPruneSeqs.get(sid) : void 0;
942
+ let source = "host";
943
+ if (own !== void 0) {
944
+ const ours = shadowed.some((seq) => own.seqs.has(seq));
945
+ // A host prune that merely follows ours within the same step still
946
+ // counts as host work; only overlapping seqs mean "we did this".
947
+ if (ours) source = "engram";
948
+ if (Date.now() - own.at > 120_000) ownPruneSeqs.delete(sid);
949
+ }
950
+ if (source === "engram") return;
951
+ ledger.notePrune(sid, workspace, {
952
+ charsRemoved: tokens * CHARS_PER_TOKEN,
953
+ rewriteChars: estimatePrefixChars(session) + tokens * CHARS_PER_TOKEN,
954
+ source: "host",
955
+ });
956
+ });
957
+ disposers.push(disposeLedgerPrune);
889
958
  }
890
959
 
891
960
  // JIT observers: tool results (error recurrence / file focus) and goal
@@ -1052,6 +1121,14 @@ export function apply(ctx, config) {
1052
1121
  // surface is already rewritten when this hook runs).
1053
1122
  const removed = Number.isFinite(report?.charsRemoved) ? report.charsRemoved : 0;
1054
1123
  const prefixChars = estimatePrefixChars(session);
1124
+ if (typeof session?.id === "string") {
1125
+ const seqs = new Set();
1126
+ for (const item of report?.pruned ?? []) {
1127
+ const seq = item?.seq ?? item?.event?.seq ?? item?.payload?.seq;
1128
+ if (Number.isFinite(seq)) seqs.add(seq);
1129
+ }
1130
+ ownPruneSeqs.set(session.id, { seqs, at: Date.now() });
1131
+ }
1055
1132
  ledger?.notePrune(session?.id, workspace, {
1056
1133
  charsRemoved: removed,
1057
1134
  rewriteChars: prefixChars + removed,
@@ -1077,12 +1154,22 @@ export function apply(ctx, config) {
1077
1154
  ctx.inject(["toolResultPruner"], (prunerCtx) => {
1078
1155
  const dispose = installBoundaryPrune(prunerCtx, resolved, log, boundaryHooks, {
1079
1156
  allowFire: boundaryGate,
1157
+ // Durability for "armed but never fires": this plane sees the host's
1158
+ // own sessions; the agent-preset realm reports the same shape under
1159
+ // plane "preset" (lib/prune.js). Comparing the two tells us where a
1160
+ // missing boundary went.
1161
+ onAttempt: (attempt) => {
1162
+ void recordPruneAttempt({ plane: "host", ...attempt });
1163
+ },
1080
1164
  });
1081
1165
  // L1 provably can (or cannot) fire from here on: record it for the
1082
1166
  // durable snapshot, then rewrite it — the first flush may have landed
1083
1167
  // before the pruner activated, and this status line is what users
1084
1168
  // read when "节省" stays at 0.
1085
1169
  boundaryPruneState = boundaryPruneAvailability(prunerCtx, resolved);
1170
+ // Mirror it onto the live view so `/overview` can answer "is the only
1171
+ // ledger credit path actually able to fire?" without reading the file.
1172
+ service.compactionStatus.boundaryPrune = boundaryPruneState;
1086
1173
  flushStatus();
1087
1174
  return dispose;
1088
1175
  });