dsh-engram 0.3.4 → 0.3.5

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/lib/context-gc.js CHANGED
@@ -12,12 +12,18 @@
12
12
  * with no re-fetchable backing) get a scoped LLM narrative — the safety net,
13
13
  * configurable off for a pure-mechanical zero-LLM path.
14
14
  *
15
- * The 6 pi-esr GC constraints:
15
+ * The 6 pi-esr GC constraints (+ the harness shrink gate that keeps them legal):
16
16
  * - indexed-output-evictable → pointer to `engram_detail(id)` / `engram_recall`
17
17
  * - stable-task-evictable → pointer to the [ESR] block / `esr_ready`
18
18
  * - working-set-protected → active tasks restated in the summary
19
19
  * - pointer-salience → every evicted category has an explicit re-fetch call
20
20
  * - no-provenance-no-evict → un-provenanced turns via scoped narrative or verbatim
21
+ * - shrink-gated → the framed checkpoint must be STRICTLY SMALLER
22
+ * than the evicted span (`fitSummaryToSpan`): the harness refuses and rolls
23
+ * back any summary that is not smaller, which otherwise blocks every auto
24
+ * compaction and pushes the session into the model-side context overflow.
25
+ * The pointer/narrative body is therefore fitted under the span with the
26
+ * host token meter, truncating tail detail (still durable in the log).
21
27
  *
22
28
  * Layout: the pure (zero-LLM, dependency-free) layer lives here; the DSH
23
29
  * `CompactionEngine` binding is `loadContextGcEngine`, behind a guarded dynamic
@@ -67,6 +73,152 @@ export function blockText(content) {
67
73
  return out.trim();
68
74
  }
69
75
 
76
+ /**
77
+ * The durable checkpoint framing the harness applies to a compaction summary
78
+ * before accepting it (`frameSummary()` in dsh-compaction-basic): the summary
79
+ * body is wrapped in a preamble + `<compacted-summary>` tags and the WHOLE
80
+ * framed user message must estimate strictly SMALLER than the evicted span,
81
+ * or the harness throws `summary is not smaller than the shadowed content`
82
+ * and rolls the compaction back. These constants mirror that framing so the
83
+ * Context GC can self-predict acceptance with the host token meter (verified
84
+ * to reproduce the harness's estimate exactly); a small safety margin absorbs
85
+ * any harness drift.
86
+ */
87
+ const FRAME_PREAMBLE =
88
+ "This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.";
89
+ const FRAME_OPEN_TAG = "<compacted-summary>";
90
+ const FRAME_CLOSE_TAG = "</compacted-summary>";
91
+
92
+ /** Best-effort token estimate of one derived message through the host meter. */
93
+ function estimateMessageTokens(meter, message) {
94
+ if (typeof meter?.estimateMessage !== "function") return 0;
95
+ try {
96
+ const tokens = meter.estimateMessage(message);
97
+ return Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Exact token estimate of the span a compaction is about to shadow: the sum of
105
+ * the evicted region's derived messages through the SAME token meter the
106
+ * harness compares against (verified to equal the harness's shadowed count).
107
+ * Returns 0 when no meter is available — callers then skip the shrink gate
108
+ * rather than guess.
109
+ * @param {object|undefined} meter - `ctx.tokenMeter` (host), or undefined.
110
+ * @param {Array<object>} messages - the region messages passed to `summarize`.
111
+ * @returns {number}
112
+ */
113
+ export function estimateShadowedTokens(meter, messages) {
114
+ const list = Array.isArray(messages) ? messages : [];
115
+ let total = 0;
116
+ for (const message of list) total += estimateMessageTokens(meter, message);
117
+ return total;
118
+ }
119
+
120
+ /** The harness-framed checkpoint user message for a summary body. */
121
+ function framedCheckpointMessage(bodyText) {
122
+ return {
123
+ role: "user",
124
+ content: [
125
+ { type: "text", text: `${FRAME_PREAMBLE}\n\n${FRAME_OPEN_TAG}` },
126
+ { type: "text", text: bodyText },
127
+ { type: "text", text: FRAME_CLOSE_TAG },
128
+ ],
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Token estimate of the framed checkpoint the harness will generate for this
134
+ * body (mirrors `frameSummary` + `createUserMessage` + `estimateMessage`;
135
+ * verified byte-identical to the live meter). Without a meter, a conservative
136
+ * chars→tokens heuristic is used — divided by two so both CJK and latin
137
+ * over-estimate, biasing toward trimming more, never less.
138
+ * @param {object|undefined} meter - `ctx.tokenMeter`, or undefined.
139
+ * @param {string} bodyText - the summary body inside the checkpoint.
140
+ * @returns {number}
141
+ */
142
+ export function estimateFramedTokens(meter, bodyText) {
143
+ if (typeof meter?.estimateMessage === "function") {
144
+ try {
145
+ const tokens = meter.estimateMessage(framedCheckpointMessage(bodyText));
146
+ if (Number.isFinite(tokens)) return Math.max(1, tokens);
147
+ } catch {
148
+ // fall through to the char heuristic
149
+ }
150
+ }
151
+ const text = typeof bodyText === "string" ? bodyText : "";
152
+ return (
153
+ Math.ceil((FRAME_PREAMBLE.length + FRAME_OPEN_TAG.length + FRAME_CLOSE_TAG.length + text.length) / 2) + 8
154
+ );
155
+ }
156
+
157
+ /** Truncation note appended when the checkpoint must shrink to fit the span. */
158
+ const FIT_SUFFIX = "\n_…(full detail stays re-fetchable via the engram/ESR tools; see the session log)_";
159
+
160
+ /**
161
+ * Shrink a Context GC summary body so the framed checkpoint stays strictly
162
+ * under the evicted span — the harness's hard shrink gate. Without this, a
163
+ * large verbatim/pointer body makes every automatic pressure / overflow
164
+ * compaction roll back (`summary is not smaller than the shadowed content`),
165
+ * the context never shrinks, and the session eventually hits the model-side
166
+ * context overflow.
167
+ *
168
+ * Truncation keeps the largest PREFIX that still fits — the pointer head and
169
+ * working set survive, only tail detail is cut — and appends a short note when
170
+ * the span is large enough to afford one. Exact under the host token meter;
171
+ * deterministic everywhere; degrades to an empty body for spans so small that
172
+ * the exact framing overhead does not fit (the stock LLM summarizer would be
173
+ * refused by the harness for those too).
174
+ *
175
+ * @param {string} bodyText - the full Context GC body.
176
+ * @param {number} shadowedTokens - evicted-span token estimate (0 = unknown).
177
+ * @param {{meter?: object, margin?: number}} [opts]
178
+ * @returns {{ text: string, framed: number, shadowed: number, trimmed: boolean }}
179
+ */
180
+ export function fitSummaryToSpan(bodyText, shadowedTokens, opts = {}) {
181
+ const meter = opts?.meter;
182
+ const margin = opts?.margin ?? 4;
183
+ const budget = shadowedTokens - margin;
184
+ if (!Number.isFinite(shadowedTokens) || shadowedTokens <= 0 || budget <= 1) {
185
+ return {
186
+ text: bodyText,
187
+ framed: estimateFramedTokens(meter, bodyText),
188
+ shadowed: shadowedTokens,
189
+ trimmed: false,
190
+ };
191
+ }
192
+ const source = typeof bodyText === "string" && bodyText.length > 0 ? bodyText : "";
193
+ if (estimateFramedTokens(meter, source) < budget) {
194
+ return {
195
+ text: source,
196
+ framed: estimateFramedTokens(meter, source),
197
+ shadowed: shadowedTokens,
198
+ trimmed: false,
199
+ };
200
+ }
201
+ // Largest prefix of the source that fits WITHOUT the note; the note is only
202
+ // re-added when the budget still affords it (the note itself is ~25 tokens).
203
+ let lo = 0;
204
+ let hi = source.length;
205
+ let best = 0;
206
+ while (lo <= hi) {
207
+ const mid = Math.floor((lo + hi) / 2);
208
+ if (estimateFramedTokens(meter, source.slice(0, mid)) < budget) {
209
+ best = mid;
210
+ lo = mid + 1;
211
+ } else {
212
+ hi = mid - 1;
213
+ }
214
+ }
215
+ const prefix = source.slice(0, best);
216
+ const withNote = `${prefix}${FIT_SUFFIX}`;
217
+ const text = best > 0 && estimateFramedTokens(meter, withNote) < budget ? withNote : prefix;
218
+ const trimmed = text !== source;
219
+ return { text, framed: estimateFramedTokens(meter, text), shadowed: shadowedTokens, trimmed };
220
+ }
221
+
70
222
  function addSet(set, value) {
71
223
  const v = String(value ?? "").trim();
72
224
  if (v.length > 0) set.add(v);
@@ -466,6 +618,57 @@ export async function loadCompactionEngine(ctx, config = {}, deps = {}, { mode =
466
618
  return loadContextGcEngine(ctx, config, deps);
467
619
  }
468
620
 
621
+ /**
622
+ * Mount the compaction engine onto `ctx` — THE shared entry both planes use
623
+ * (host plane: the main engram plugin's `apply`; preset plane: the
624
+ * `dsh-engram/compaction` row shown in an agent preset's `compaction` group).
625
+ *
626
+ * `config.gcReplacesCompaction === false` → mount the bare default LLM
627
+ * summarizer instead (keeps the `compaction` service alive after the profile
628
+ * patch disabled the base `compaction-basic` row); otherwise mount the
629
+ * ContextGcEngine override. `deps.readWorkspace(ws, report)` is how the engine
630
+ * reaches engram/ESR state for pointer classification — the caller supplies
631
+ * its own lazy-domain binding.
632
+ *
633
+ * Fully contained: an unimportable backend or a duplicate `compaction`
634
+ * provider (cordis refuses second registration) only warns and returns null —
635
+ * the host keeps whatever compaction it already has, never a crash.
636
+ *
637
+ * @returns {Promise<Function|null>} the mounted engine class, or null.
638
+ */
639
+ export async function mountCompactionEngine(ctx, config = {}, deps = {}) {
640
+ const mode = config.gcReplacesCompaction === false ? "default" : "context-gc";
641
+ const engineDeps =
642
+ mode === "context-gc"
643
+ ? {
644
+ narrativeEnabled: config.gcNarrative !== false,
645
+ narrativeMaxTokens: config.gcNarrativeMaxTokens ?? 1024,
646
+ narrativeMaxChars: config.gcNarrativeMaxChars ?? 4000,
647
+ ...deps,
648
+ }
649
+ : {};
650
+ const Engine = await loadCompactionEngine(ctx, { auto: true }, engineDeps, { mode });
651
+ if (!Engine) {
652
+ ctx?.logger?.warn?.("engram context-gc: dsh-compaction-basic unavailable — no compaction engine");
653
+ return null;
654
+ }
655
+ try {
656
+ new Engine();
657
+ } catch (error) {
658
+ // Duplicate `compaction` provider (base row not disabled) or constructor
659
+ // refusal: keep whatever is already registered, never crash the host.
660
+ ctx?.logger?.warn?.(
661
+ `engram context-gc mount skipped (${error instanceof Error ? error.message : String(error)}) — keeping the existing compaction service`,
662
+ );
663
+ return null;
664
+ }
665
+ ctx?.logger?.info?.(
666
+ `engram context-gc: compaction = ${mode === "context-gc" ? "Context GC (mechanical eviction + re-fetch pointers)" : "default LLM summarizer (gcReplacesCompaction:false)"}`,
667
+ );
668
+ return Engine;
669
+ }
670
+
671
+
469
672
  /**
470
673
  * Load the DSH binding: a `ContextGcEngine extends BasicCompactionEngine`
471
674
  * whose sole override is `summarize()`. Returns `null` when
@@ -542,9 +745,38 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
542
745
  callLlm,
543
746
  });
544
747
 
545
- const summaryText = buildContextGcSummary(report, safeContext, { turns, narrative });
748
+ let summaryText = buildContextGcSummary(report, safeContext, { turns, narrative });
749
+
750
+ // The harness refuses any checkpoint whose framed token estimate is >=
751
+ // the evicted span (see fitSummaryToSpan). Fit the body under that span
752
+ // so automatic pressure / context-overflow compaction COMMITS instead of
753
+ // rolling back — otherwise the session only grows until the model-side
754
+ // context overflow fires.
755
+ const meter = this.ctx?.tokenMeter;
756
+ const shadowedTokens = estimateShadowedTokens(meter, messages);
757
+ const fit = fitSummaryToSpan(summaryText, shadowedTokens, { meter });
758
+ if (fit.trimmed && typeof this.ctx?.logger?.info === "function") {
759
+ this.ctx.logger.info(
760
+ `engram context-gc: checkpoint trimmed to fit the evicted span (${fit.framed} framed vs ${fit.shadowed} shadowed tokens)`,
761
+ );
762
+ }
763
+ summaryText = fit.text;
764
+
546
765
  const result = { summary: [{ type: "text", text: summaryText }] };
547
766
  if (narrative.llm && typeof narrative.llm === "object") Object.assign(result, narrative.llm);
767
+ // The harness's durable `compaction/summary` record ALWAYS carries
768
+ // `provider`/`model` (spread unconditionally; undefined values reject the
769
+ // whole event as non-JSON-serializable). The mechanical path makes no LLM
770
+ // call, so stamp the conversation's routed target — the same model the
771
+ // default summarizer would have used — as the record's provenance.
772
+ const target = this._narrativeTarget(agent);
773
+ if (target && typeof target.provider === "string" && typeof target.model === "string") {
774
+ result.provider = result.provider ?? target.provider;
775
+ result.model = result.model ?? target.model;
776
+ }
777
+ if (result.maxTokens === undefined && this.config && Number.isFinite(this.config.maxTokens)) {
778
+ result.maxTokens = this.config.maxTokens;
779
+ }
548
780
  return result;
549
781
  }
550
782
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * dsh-engram: DSH goal-domain integration.
3
+ *
4
+ * The harness's goal service persists a durable `goal/change` session event on
5
+ * every goal mutation. This module listens for those events and turns the two
6
+ * terminal states into memories, so a completed / blocked goal survives the
7
+ * session — deterministic, zero LLM, purely mechanical:
8
+ *
9
+ * - complete → `handoff` memory (high signal, tag `goal`)
10
+ * - block → `error` memory (tag `goal` + `blocked`) — the blocker is a
11
+ * real, recall-worthy signal
12
+ *
13
+ * The goal snapshot fields are read verbatim; nothing here calls a model and
14
+ * the listener never throws into the session pipeline (fully contained).
15
+ */
16
+
17
+ import { workspaceKey as wk } from "./util.js";
18
+
19
+ function isRecord(v) {
20
+ return v !== null && typeof v === "object" && !Array.isArray(v);
21
+ }
22
+
23
+ /**
24
+ * Pure reducer: map a `goal/change` session-event payload to a memory entry,
25
+ * or null when the event is not a terminal goal state worth sedimenting.
26
+ * Deterministic and unit-testable without the harness.
27
+ */
28
+ export function goalChangeToMemory(change) {
29
+ if (!isRecord(change) || change.kind !== "goal/change") return null;
30
+ if (change.operation !== "complete" && change.operation !== "block") return null;
31
+ const goal = change.goal;
32
+ if (!isRecord(goal) || typeof goal.objective !== "string") return null;
33
+ const objective = goal.objective.trim().slice(0, 400);
34
+ if (objective.length === 0) return null;
35
+ if (change.operation === "complete") {
36
+ return {
37
+ kind: "handoff",
38
+ text: `goal complete: ${objective}`,
39
+ tags: ["goal", "auto-captured", "complete"],
40
+ signal: 0.6,
41
+ };
42
+ }
43
+ const reason = typeof goal.blockedReason === "string" && goal.blockedReason.trim().length > 0
44
+ ? ` — ${goal.blockedReason.trim().slice(0, 160)}`
45
+ : "";
46
+ return {
47
+ kind: "error",
48
+ text: `goal blocked: ${objective}${reason}`,
49
+ tags: ["goal", "auto-captured", "blocked"],
50
+ signal: 0.4,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Install the `session/event` listener that auto-sediments terminal goals.
56
+ * `storeAdapter` is any { storeMemory(entry) -> Promise } face (the same lazy
57
+ * adapter auto-capture uses); it is resolved per event, so early events that
58
+ * beat the first domain open are simply dropped. Returns the disposer.
59
+ */
60
+ export function installGoalCapture(ctx, storeAdapter, log) {
61
+ const onEvent = (session, event) => {
62
+ try {
63
+ if (event?.type !== "goal/change") return;
64
+ const entry = goalChangeToMemory(event.data);
65
+ if (entry === null) return;
66
+ const cwd = session?.header?.cwd;
67
+ const sessionId = session?.id;
68
+ if (typeof cwd !== "string" || cwd.length === 0) return;
69
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
70
+ storeAdapter
71
+ .storeMemory({
72
+ workspace: wk(cwd),
73
+ kind: entry.kind,
74
+ text: entry.text,
75
+ tags: entry.tags,
76
+ signal: entry.signal,
77
+ filePath: null,
78
+ sessionId,
79
+ seq: 0,
80
+ })
81
+ .then(() => {})
82
+ .catch((error) => log?.warn?.(`engram goal capture failed: ${String(error)}`));
83
+ } catch (error) {
84
+ // fully contained — a capture bug must never disturb the session pipeline
85
+ log?.warn?.(`engram goal capture threw: ${String(error)}`);
86
+ }
87
+ };
88
+ return ctx.on("session/event", onEvent);
89
+ }
@@ -51,7 +51,11 @@ export function isProvenProcedure(m, config) {
51
51
  */
52
52
  export function selectIndexMemories(store, workspace, config) {
53
53
  const all = store.listMemories(workspace, 200);
54
+ // Stale truth (a newer memory marked itself as replacing that row) never
55
+ // earns an index line — the current statement is the one worth surfacing.
56
+ const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
54
57
  return all
58
+ .filter((m) => !supersededBy.has(m.id))
55
59
  .filter(
56
60
  (m) =>
57
61
  (isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
package/lib/index.js CHANGED
@@ -6,8 +6,10 @@
6
6
  * - a compact, per-session-frozen [ENGRAM] symbolic index injected through a
7
7
  * systemPrompt section (order 40) — prefix-stable for KV cache reuse
8
8
  * - an [ESR] task/closure block (order 41) listing evidence gaps
9
- * - six tight tools: engram_store / engram_recall / engram_detail / esr_task /
10
- * esr_close / esr_link
9
+ * - fourteen tight tools: memory plane (engram_store / engram_recall /
10
+ * engram_detail) + ESR protocol (esr_task / esr_close / esr_link / esr_dep /
11
+ * esr_claim / esr_unclaim / esr_ready / esr_status / esr_node / esr_gc /
12
+ * esr_model)
11
13
  * - storage on ctx.storageDomain (JSON unit dsh_engram), reads synchronous
12
14
  *
13
15
  * All model-visible writes are immediate (no approval gate — zero friction,
@@ -20,11 +22,15 @@ import { resolve as resolvePath } from "node:path";
20
22
  import { openEngramDomain } from "./store.js";
21
23
  import { registerTools } from "./tools.js";
22
24
  import { makeCaptureHandler } from "./capture.js";
25
+ import { installGoalCapture } from "./goal-capture.js";
23
26
  import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
24
27
  import { makeTriggerRecorder } from "./trigger.js";
28
+ import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
25
29
  import { installEngramSettings } from "./settings.js";
26
30
  import { makeEngramRoutes } from "./api.js";
27
- import { loadCompactionEngine, collectPointerContext } from "./context-gc.js";
31
+ import { collectPointerContext, mountCompactionEngine } from "./context-gc.js";
32
+ import { provisionWebCompaction } from "./web-provision.js";
33
+ import { writeContextGcStatus } from "./status.js";
28
34
  import { workspaceKey as wk } from "./util.js";
29
35
 
30
36
  /** Stable cordis plugin name. */
@@ -88,9 +94,30 @@ const DEFAULTS = {
88
94
  minErrorHits: 2,
89
95
  // P3: an active task with no update for this many days is surfaced as stale.
90
96
  staleTaskDays: 14,
97
+ // Session-end todo continuity (audit follow-up): when a session is disposed
98
+ // while its plan still has pending todos, auto-sink them into ESR as DRAFT
99
+ // tasks (deduped by name, capped by maxTasksPerWorkspace). During the session
100
+ // nothing is auto-written — todo stays cheap/ephemeral and the [ESR] prefix
101
+ // stays stable; the sink only lands drafts, which still need the deliberate
102
+ // esr_claim / esr_task step to become active.
103
+ autoSinkTodosOnEnd: true,
104
+ // Web-plane auto-wiring for Context GC: web sessions resolve `compaction`
105
+ // inside the agent preset's own realm, which a profile patch cannot reach,
106
+ // so the plugin edits every stock-layout preset's `compaction` group once —
107
+ // the default preset plus the whole roster (shipped + user roots) — swapping
108
+ // the `compaction-basic` row for `dsh-engram/compaction` (backup + strict
109
+ // validation + idempotent; a preset with a custom compaction layout — or
110
+ // none, like `minimal` — is never touched). Off = every preset keeps DSH's
111
+ // default LLM summarizer; the host plane (headless/TUI/base) is unaffected.
112
+ autoWebCompaction: true,
113
+ // Memory-to-memory semantics (zero LLM): when ON, an entity-anchored write
114
+ // that carries a replacement/negation cue ("改用 / no longer / switched…")
115
+ // auto-marks the matching older same-entity+kind memory as superseded.
116
+ // OFF by default — explicit `supersedes` is always the primary path.
117
+ autoSupersede: false,
91
118
  };
92
119
 
93
- function resolveConfig(config) {
120
+ export function resolveConfig(config) {
94
121
  const src = config && typeof config === "object" ? config : {};
95
122
  const out = { ...DEFAULTS };
96
123
  for (const key of Object.keys(DEFAULTS)) {
@@ -167,6 +194,12 @@ export function apply(ctx, config) {
167
194
  captureStats: { total: 0, git: 0, file: 0, error: 0 },
168
195
  /** Last GC sweep totals (GUI overview observability). */
169
196
  gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
197
+ /** Live Context GC status (API overview + durable status file). */
198
+ compactionStatus: {
199
+ host: "pending",
200
+ web: { action: "pending", presets: [] },
201
+ config: { autoWebCompaction: resolved.autoWebCompaction, gcReplacesCompaction: resolved.gcReplacesCompaction, gcNarrative: resolved.gcNarrative },
202
+ },
170
203
  /** Recompute the exact [ENGRAM] block for a workspace (GUI overview cost). */
171
204
  renderIndexBlock(workspace) {
172
205
  if (opened === void 0) return "";
@@ -313,6 +346,117 @@ export function apply(ctx, config) {
313
346
  // tools
314
347
  disposers.push(registerTools(ctx, service));
315
348
 
349
+ // Context GC (host plane) — the auto-GC that REPLACES DSH's built-in
350
+ // lossy LLM-summary compaction. Mounts the `compaction` service on THIS
351
+ // fiber (cordis removes it again when the fiber tears down, so reloading /
352
+ // unloading engram rolls back automatically). The profile patch disables
353
+ // the base `compaction-basic` row, making engram the sole provider:
354
+ // `gcReplacesCompaction:true` (default) → ContextGcEngine; `false` → a bare
355
+ // BasicCompactionEngine so the `compaction` service never disappears while
356
+ // dsh-engram is installed. Web profiles mount per-session compaction inside
357
+ // the agent preset's own realm (see README); this host-plane provider
358
+ // covers headless/TUI/base profiles and any host-plane consumer.
359
+ const hostCompactionPromise = mountCompactionEngine(ctx, resolved, {
360
+ readWorkspace: async (workspace, report) => {
361
+ if (!workspace) return null;
362
+ try {
363
+ const domain = await service.getDomain();
364
+ return collectPointerContext(domain, wk(workspace), report);
365
+ } catch {
366
+ return null;
367
+ }
368
+ },
369
+ });
370
+ disposers.push(() => {
371
+ // The engine's service registration is fiber-scoped and auto-removed on
372
+ // teardown; this only settles the async mount and surfaces late errors.
373
+ void hostCompactionPromise.catch(() => {});
374
+ });
375
+
376
+ // Context GC status: record the settled host/web facts and flush a durable
377
+ // snapshot to $DSH_HOME/engram/context-gc.status.json once BOTH planes have
378
+ // settled (the web inject may never fire on a profile without agentPresets,
379
+ // so a bounded timer marks the web plane done for host-only profiles).
380
+ let hostSettled = false;
381
+ let webSettled = resolved.autoWebCompaction === false;
382
+ let statusTimer = void 0;
383
+ const flushStatus = () => {
384
+ if (!hostSettled || !webSettled) return;
385
+ if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
386
+ void writeContextGcStatus({
387
+ planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
388
+ host: service.compactionStatus.host,
389
+ web: service.compactionStatus.web,
390
+ config: service.compactionStatus.config,
391
+ configSource: {
392
+ autoWebCompaction: resolved.autoWebCompaction,
393
+ gcReplacesCompaction: resolved.gcReplacesCompaction,
394
+ gcNarrative: resolved.gcNarrative,
395
+ },
396
+ }).catch(() => {});
397
+ };
398
+ void hostCompactionPromise
399
+ .then((Engine) => {
400
+ hostSettled = true;
401
+ service.compactionStatus.host = Engine
402
+ ? (resolved.gcReplacesCompaction ? "context-gc" : "default")
403
+ : "unavailable";
404
+ flushStatus();
405
+ })
406
+ .catch(() => {
407
+ hostSettled = true;
408
+ service.compactionStatus.host = "unavailable";
409
+ flushStatus();
410
+ });
411
+
412
+ // Web-plane auto-wiring (Context GC): web sessions resolve `compaction`
413
+ // inside the agent preset's own realm, which a profile patch cannot reach.
414
+ // When the `agentPresets` service is present, swap the stock
415
+ // `compaction-basic` row for `dsh-engram/compaction` in every stock-layout
416
+ // preset — the default one plus the whole roster (shipped + user roots) —
417
+ // so a session composed from any preset gets Context GC. Surgical,
418
+ // idempotent, backed up, validated; a preset with a custom compaction
419
+ // layout (or none) is never touched. The row's engine config is derived
420
+ // from the settings knobs (`gcReplacesCompaction` / `gcNarrative`), so
421
+ // changing them re-provisions the web rows on the next boot. Flip
422
+ // `autoWebCompaction:false` to stop auto-wiring on later boots (it does
423
+ // not un-wire already-wired presets — use `npm run web-compaction:revert`
424
+ // for that). Best-effort: a failure only warns.
425
+ if (resolved.autoWebCompaction !== false) {
426
+ ctx.inject(["agentPresets"], (webCtx) => {
427
+ const agentPresets = webCtx.get("agentPresets");
428
+ provisionWebCompaction(agentPresets, {
429
+ rowConfig: {
430
+ gcReplacesCompaction: resolved.gcReplacesCompaction,
431
+ gcNarrative: resolved.gcNarrative,
432
+ },
433
+ log: (line) => webCtx.logger.info(line),
434
+ })
435
+ .then((report) => {
436
+ webSettled = true;
437
+ service.compactionStatus.web = { action: report.action, presets: report.presets };
438
+ flushStatus();
439
+ })
440
+ .catch((error) => {
441
+ webSettled = true;
442
+ service.compactionStatus.web = { action: "failed", presets: [], error: String(error?.message ?? error) };
443
+ webCtx.logger.warn(`engram web-provision failed: ${String(error?.message ?? error)}`);
444
+ flushStatus();
445
+ });
446
+ });
447
+ // Host-only profiles never provide `agentPresets`; settle the web plane
448
+ // after a bounded wait so the status flush still happens there.
449
+ statusTimer = setTimeout(() => {
450
+ if (!webSettled) {
451
+ webSettled = true;
452
+ flushStatus();
453
+ }
454
+ }, 15_000);
455
+ setTimeout(() => { statusTimer = void 0; }, 16_000);
456
+ } else {
457
+ flushStatus();
458
+ }
459
+
316
460
  // systemPrompt sections (frozen per session)
317
461
  const systemPrompt = ctx.get("systemPrompt");
318
462
  if (systemPrompt !== void 0) {
@@ -359,6 +503,38 @@ export function apply(ctx, config) {
359
503
  });
360
504
  disposers.push(disposeTrigger);
361
505
 
506
+ // session-end todo auto-sink: a session whose plan still has pending todos
507
+ // lands them as ESR drafts at its teardown edge, so the plan survives the
508
+ // session (flip `autoSinkTodosOnEnd` off to opt out). Live-toggleable — the
509
+ // flag is read per event. Fully contained: a sink failure only warns.
510
+ const disposeSink = ctx.on("session/disposed", (session) => {
511
+ if (resolved.autoSinkTodosOnEnd === false) return;
512
+ const cwd = session?.header?.cwd;
513
+ const sessionId = session?.id;
514
+ if (typeof cwd !== "string" || cwd.length === 0) return;
515
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
516
+ const todos = pendingTodosFromSession(session);
517
+ if (todos.length === 0) return;
518
+ const workspace = wk(cwd);
519
+ service
520
+ .getDomain()
521
+ .then((domain) =>
522
+ sinkPendingTodos(domain, workspace, todos, {
523
+ sessionId,
524
+ maxTasks: resolved.maxTasksPerWorkspace,
525
+ }),
526
+ )
527
+ .then((report) => {
528
+ if (report.created.length > 0) {
529
+ log?.info?.(`engram todo auto-sink: ${report.created.length} pending todo(s) → ${report.created.length} ESR draft(s) in ${workspace}`);
530
+ } else if (report.existed.length > 0 || report.atCap.length > 0) {
531
+ log?.info?.(`engram todo auto-sink: ${report.existed.length} already in ESR, ${report.atCap.length} skipped (cap) in ${workspace}`);
532
+ }
533
+ })
534
+ .catch((error) => log?.warn?.(`engram todo auto-sink failed: ${String(error)}`));
535
+ });
536
+ disposers.push(disposeSink);
537
+
362
538
  // zero-LLM auto-capture
363
539
  if (resolved.autoCapture) {
364
540
  // The capture handler needs a storeMemory face, but the domain opens
@@ -374,12 +550,28 @@ export function apply(ctx, config) {
374
550
  }
375
551
  return domain.storeMemory(...args);
376
552
  },
553
+ // failure→fix closure reads (sync; [] until the domain is open)
554
+ listMemories(workspace, limit) {
555
+ const domain = service.openedDomain();
556
+ return domain === void 0 ? [] : domain.listMemories(workspace, limit);
557
+ },
558
+ // failure→fix closure tags prior error rows as resolved (async)
559
+ tagMemory(workspace, id, tag) {
560
+ const domain = service.openedDomain();
561
+ return domain === void 0 ? Promise.resolve(false) : domain.tagMemory(workspace, id, tag);
562
+ },
377
563
  };
378
564
  const capture = makeCaptureHandler(captureStore, resolved, log, service.captureStats);
379
565
  const disposeCapture = ctx.on("tools/result", (exec, result) => {
380
566
  capture(exec, result);
381
567
  });
382
568
  disposers.push(disposeCapture);
569
+
570
+ // DSH goal-domain integration: a completed/blocked goal auto-sediments
571
+ // as a memory (`goal/change` session event → handoff/error row), so the
572
+ // outcome survives the session without any LLM call.
573
+ const disposeGoal = installGoalCapture(ctx, captureStore, log);
574
+ disposers.push(disposeGoal);
383
575
  }
384
576
 
385
577
  // memory GC scheduling: one sweep after the store is reachable, then on
package/lib/redact.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * dsh-engram: deterministic secret redaction for the memory write path.
3
+ *
4
+ * Auto-captured tool results, git commit subjects and error messages can
5
+ * carry real credentials (API keys, tokens, private keys) into long-term
6
+ * memory — and later straight back into a prompt via recall/[ENGRAM]. This
7
+ * module strips them BEFORE persistence so nothing sensitive is ever stored.
8
+ *
9
+ * Rule-based and deterministic (same input ⇒ same output, zero deps, zero
10
+ * LLM). Replacement markers are derived from the secret kind, so exact
11
+ * duplicate detection and error-revival hashing keep behaving consistently
12
+ * across runs. Applied once at the single store write choke point
13
+ * (`storeMemory`), which covers engram_store, auto-capture and any future
14
+ * write path.
15
+ */
16
+
17
+ const RULES = [
18
+ // Private key blocks (longest first so later rules cannot see them).
19
+ [/-----BEGIN [A-Z0-9 ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]+PRIVATE KEY-----/g, "<REDACTED:private-key>"],
20
+ // JWT (header.payload.signature).
21
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "<REDACTED:jwt>"],
22
+ // Stripe live/test keys.
23
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, "<REDACTED:stripe>"],
24
+ // OpenAI/Anthropic/OpenRouter style sk-… keys.
25
+ [/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, "<REDACTED:sk>"],
26
+ // GitHub fine-grained / classic tokens.
27
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "<REDACTED:github>"],
28
+ // AWS access key IDs.
29
+ [/\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA)[0-9A-Z]{16}\b/g, "<REDACTED:aws>"],
30
+ // Slack workspace tokens.
31
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "<REDACTED:slack>"],
32
+ // Basic auth user:pass inside URLs.
33
+ [/\/\/[^:\/\s]+:[^@\s\/]+@/g, "//<REDACTED>@"],
34
+ // Authorization: Bearer <long token>.
35
+ [/\bBearer\s+[A-Za-z0-9._~+\/-]{16,}\b/gi, "<REDACTED:bearer>"],
36
+ // Generic key=value / key: value assignments for obvious secret keys.
37
+ [/\b(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|secret|passwd|password|persist[_-]?token|refresh[_-]?token|token)\b\s*[:=]\s*['"]?[A-Za-z0-9_\-.\/+=]{6,}['"]?/gi, "<REDACTED:key>"],
38
+ ];
39
+
40
+ /**
41
+ * Redact known secret shapes in `text`. Deterministic: identical input yields
42
+ * identical output. Non-secret prose is untouched.
43
+ */
44
+ export function redactText(text) {
45
+ let out = String(text ?? "");
46
+ for (const [re, repl] of RULES) out = out.replace(re, repl);
47
+ return out;
48
+ }