dsh-engram 0.7.4 → 0.8.6

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 (48) hide show
  1. package/README.md +93 -6
  2. package/README.zh.md +6 -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/eval/gate-audit.mjs +538 -0
  27. package/lib/api.js +70 -0
  28. package/lib/approvals.js +84 -0
  29. package/lib/boundary-prune.js +71 -5
  30. package/lib/capsule.js +134 -0
  31. package/lib/capture.js +83 -5
  32. package/lib/client.js +66 -0
  33. package/lib/compaction.js +4 -11
  34. package/lib/context-gc.js +63 -2
  35. package/lib/index-block.js +79 -4
  36. package/lib/index.js +91 -18
  37. package/lib/ledger.js +200 -10
  38. package/lib/pack-telemetry.js +10 -3
  39. package/lib/prune.js +89 -0
  40. package/lib/session-events.js +44 -2
  41. package/lib/skill-export.js +289 -0
  42. package/lib/status-insights.js +5 -0
  43. package/lib/status.js +138 -0
  44. package/lib/store.js +34 -0
  45. package/lib/usage.js +32 -3
  46. package/lib/web-provision.js +59 -14
  47. package/package.json +15 -8
  48. package/scripts/dsh-engram.mjs +229 -4
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,19 @@ 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
36
  import { makeTriggerRecorder } from "./trigger.js";
37
37
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
38
38
  import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
39
39
  import { makeEngramRoutes } from "./api.js";
40
- import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
40
+ import { collectPointerContext, committedGcSaving, mountCompactionEngine } from "./context-gc.js";
41
41
  import { provisionWebCompaction } from "./web-provision.js";
42
- import { writeContextGcStatus } from "./status.js";
42
+ import { writeContextGcStatus, recordPruneAttempt } from "./status.js";
43
43
  import { workspaceKey as wk } from "./util.js";
44
44
 
45
45
  /** Stable cordis plugin name. */
@@ -81,11 +81,23 @@ const DEFAULTS = {
81
81
  // memories that outrank the [ENGRAM] index.
82
82
  autoCapturePreferences: true,
83
83
  preferenceCapturePerSession: 8,
84
+ // v0.8-C (advisory): a subagent report above this many chars is distilled
85
+ // into a durable `handoff` capsule. The parent still receives the full text —
86
+ // DSH's tools/result is observer-only (no mutation channel) — so this buys
87
+ // durability and a measurable saving, not a smaller prompt. See lib/capsule.js.
88
+ capsuleMinSourceChars: 1500,
89
+ capsuleMaxChars: 1800,
84
90
  // [ENGRAM] index budget (token discipline)
85
91
  indexMaxLines: 12,
86
92
  indexMaxChars: 700,
87
93
  minIndexSignal: 0.4,
88
94
  promoteHits: 3,
95
+ // v0.8-A evidence-based procedure promotion: a `fixed:` procedure reaches the
96
+ // [ENGRAM] index only after this many verified successes, and only while the
97
+ // last verification is younger than procedureMaxAgeDays. A later failure
98
+ // invalidates it immediately (see store.recordProcedureOutcome).
99
+ procedureMinEvidence: 3,
100
+ procedureMaxAgeDays: 14,
89
101
  // retention / caps
90
102
  expireDays: 180,
91
103
  maxMemoriesPerWorkspace: 2000,
@@ -186,9 +198,13 @@ const DEFAULTS = {
186
198
  // (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
187
199
  // todo-progress boundaries cover 79% of replay exposure; the repayment
188
200
  // 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,
201
+ //
202
+ // Default OFF since v0.7.11: 15 h of live accounting showed the host prunes
203
+ // over-threshold results by itself (64,200 chars across two sessions with
204
+ // zero engram involvement), while the boundary source never fired in an
205
+ // ordinary 220-request session — it only pays for itself in planning
206
+ // sessions that actually use todos/goals. `true` is the opt-in.
207
+ boundaryPrune: false,
192
208
  // Adaptive off-switch (v0.6-B): the Context Ledger vetoes further fires in a
193
209
  // workspace whose own accounting is net negative after real fires. Evidence
194
210
  // can only turn pruning OFF — never on. `false` = repayment gate only.
@@ -422,6 +438,8 @@ export function apply(ctx, config) {
422
438
 
423
439
  const service = {
424
440
  config: resolved,
441
+ /** Where local (non-store) engram state lives — approvals, telemetry, ledger. */
442
+ engramHome: () => engramHome(),
425
443
  getDomain,
426
444
  openedDomain: () => opened,
427
445
  recorder,
@@ -574,7 +592,7 @@ export function apply(ctx, config) {
574
592
  if (!pendingRecall && !warnedUnreadableLog) {
575
593
  warnedUnreadableLog = true;
576
594
  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.",
595
+ `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
596
  );
579
597
  }
580
598
  } else {
@@ -668,6 +686,9 @@ export function apply(ctx, config) {
668
686
 
669
687
  ctx.effect(() => {
670
688
  const disposers = [];
689
+ // sessionId → { seqs, at }: the prunes this plugin caused, so the
690
+ // compaction/prune observer can tell engram's savings from the host's.
691
+ const ownPruneSeqs = new Map();
671
692
 
672
693
  // Settings card edits (volatile Config fields) hot-commit into the refs
673
694
  // in place; re-read them so `resolved` reflects new values for new
@@ -703,16 +724,10 @@ export function apply(ctx, config) {
703
724
  return null;
704
725
  }
705
726
  },
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
- },
727
+ // Credit is committed-record-driven (see the `compaction/summary`
728
+ // listener below), not summarize()-driven: a checkpoint the harness
729
+ // rejects or overrides saves nothing, and crediting it would make the
730
+ // ledger claim savings that never landed.
716
731
  });
717
732
  disposers.push(() => {
718
733
  // The engine's service registration is fiber-scoped and auto-removed on
@@ -735,6 +750,7 @@ export function apply(ctx, config) {
735
750
  if (!hostSettled || !webSettled) return;
736
751
  if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
737
752
  void writeContextGcStatus({
753
+ pid: process.pid,
738
754
  planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
739
755
  host: service.compactionStatus.host,
740
756
  web: service.compactionStatus.web,
@@ -886,6 +902,45 @@ export function apply(ctx, config) {
886
902
  ledger.dispose(session?.id);
887
903
  });
888
904
  disposers.push(disposeLedgerSession);
905
+ // Compaction credit, committed-record-driven: only a summary that carries
906
+ // the Context GC footer AND actually landed in the session counts. The
907
+ // engine's own measurement (`summarize`) is not evidence — the harness can
908
+ // reject or override it, and then nothing was saved.
909
+ const disposeLedgerCompaction = ctx.on("session/event", (session, event) => {
910
+ const credit = committedGcSaving(event);
911
+ if (credit === null) return;
912
+ ledger.noteCompaction(session?.id, wk(session?.header?.cwd), { charsSaved: credit.charsSaved });
913
+ });
914
+ disposers.push(disposeLedgerCompaction);
915
+ // Prune accounting: DSH prunes over-threshold tool results by itself
916
+ // (mid-step, no boundary involved). Those savings are real but they are
917
+ // NOT engram's, so they land in `hostPrunedChars` — a visible leg that
918
+ // never feeds NetTokens. Prunes we caused are already credited by the
919
+ // boundary hook (matched by seq here and skipped).
920
+ const disposeLedgerPrune = ctx.on("session/event", (session, event) => {
921
+ if (event?.type !== "compaction/prune") return;
922
+ const sid = session?.id;
923
+ const workspace = wk(session?.header?.cwd);
924
+ const tokens = Number(event?.data?.shadowedTokenCount);
925
+ if (!Number.isFinite(tokens) || tokens <= 0) return;
926
+ const shadowed = Array.isArray(event?.data?.shadowedSeqs) ? event.data.shadowedSeqs : [];
927
+ const own = typeof sid === "string" ? ownPruneSeqs.get(sid) : void 0;
928
+ let source = "host";
929
+ if (own !== void 0) {
930
+ const ours = shadowed.some((seq) => own.seqs.has(seq));
931
+ // A host prune that merely follows ours within the same step still
932
+ // counts as host work; only overlapping seqs mean "we did this".
933
+ if (ours) source = "engram";
934
+ if (Date.now() - own.at > 120_000) ownPruneSeqs.delete(sid);
935
+ }
936
+ if (source === "engram") return;
937
+ ledger.notePrune(sid, workspace, {
938
+ charsRemoved: tokens * CHARS_PER_TOKEN,
939
+ rewriteChars: estimatePrefixChars(session) + tokens * CHARS_PER_TOKEN,
940
+ source: "host",
941
+ });
942
+ });
943
+ disposers.push(disposeLedgerPrune);
889
944
  }
890
945
 
891
946
  // JIT observers: tool results (error recurrence / file focus) and goal
@@ -1052,6 +1107,14 @@ export function apply(ctx, config) {
1052
1107
  // surface is already rewritten when this hook runs).
1053
1108
  const removed = Number.isFinite(report?.charsRemoved) ? report.charsRemoved : 0;
1054
1109
  const prefixChars = estimatePrefixChars(session);
1110
+ if (typeof session?.id === "string") {
1111
+ const seqs = new Set();
1112
+ for (const item of report?.pruned ?? []) {
1113
+ const seq = item?.seq ?? item?.event?.seq ?? item?.payload?.seq;
1114
+ if (Number.isFinite(seq)) seqs.add(seq);
1115
+ }
1116
+ ownPruneSeqs.set(session.id, { seqs, at: Date.now() });
1117
+ }
1055
1118
  ledger?.notePrune(session?.id, workspace, {
1056
1119
  charsRemoved: removed,
1057
1120
  rewriteChars: prefixChars + removed,
@@ -1077,12 +1140,22 @@ export function apply(ctx, config) {
1077
1140
  ctx.inject(["toolResultPruner"], (prunerCtx) => {
1078
1141
  const dispose = installBoundaryPrune(prunerCtx, resolved, log, boundaryHooks, {
1079
1142
  allowFire: boundaryGate,
1143
+ // Durability for "armed but never fires": this plane sees the host's
1144
+ // own sessions; the agent-preset realm reports the same shape under
1145
+ // plane "preset" (lib/prune.js). Comparing the two tells us where a
1146
+ // missing boundary went.
1147
+ onAttempt: (attempt) => {
1148
+ void recordPruneAttempt({ plane: "host", ...attempt });
1149
+ },
1080
1150
  });
1081
1151
  // L1 provably can (or cannot) fire from here on: record it for the
1082
1152
  // durable snapshot, then rewrite it — the first flush may have landed
1083
1153
  // before the pruner activated, and this status line is what users
1084
1154
  // read when "节省" stays at 0.
1085
1155
  boundaryPruneState = boundaryPruneAvailability(prunerCtx, resolved);
1156
+ // Mirror it onto the live view so `/overview` can answer "is the only
1157
+ // ledger credit path actually able to fire?" without reading the file.
1158
+ service.compactionStatus.boundaryPrune = boundaryPruneState;
1086
1159
  flushStatus();
1087
1160
  return dispose;
1088
1161
  });