dsh-engram 0.3.0 → 0.3.3

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.
@@ -130,16 +130,36 @@ function enforceCharCap(lines, maxChars) {
130
130
  }
131
131
 
132
132
  /**
133
- * One-line, data-driven escalation reminder. Surfaces the workspace real
134
- * recent mem-vs-esr balance (usage rollup, last 14 days) so the model sees
135
- * its own under-proactivity at the next session and can self-correct NOW.
136
- * Returns "" when there is nothing to correct (no data, too small a sample,
137
- * or the balance is healthy) — the reminder disappears once behaviour
138
- * improves, closing the loop.
133
+ * One-line escalation reminder from an arbitrary balance view
134
+ * ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
135
+ * mem-vs-esr balance so the model sees its own under-proactivity and can
136
+ * self-correct NOW. Returns "" when there is nothing to correct (no data,
137
+ * too small a sample, or the balance is healthy) — the reminder disappears
138
+ * once behaviour improves, closing the loop. Pure, deterministic.
139
139
  */
140
- export function escalationHint(store, workspace) {
140
+ export function esrBalanceHint(balance) {
141
+ if (balance === void 0 || balance === null) return "";
142
+ const memCalls = balance.memCalls ?? 0;
143
+ const esrCalls = balance.esrCalls ?? 0;
144
+ const days = balance.days ?? 0;
145
+ // No data or too little signal — lecturing on <3 memory ops is noise.
146
+ if (memCalls < 3 || days < 1) return "";
147
+ const ratio = esrCalls / (memCalls + esrCalls);
148
+ // Healthy once esr exceeds ~a third of memory-ops volume; otherwise nudge.
149
+ if (ratio >= 0.34) return "";
150
+ return `escalate: last ${days}d ${memCalls} mem ops vs ${esrCalls} esr calls (${Math.round(ratio * 100)}%) — multi-step work → esr_task now, recurring objects → esr_node, related things → esr_link, and close with evidence (esr_close) #suggest-escalate`;
151
+ }
152
+
153
+ /**
154
+ * Balance view derived from the per-(workspace,day) `usage` table (legacy).
155
+ * The table is no longer written in production (assessment P3 moved
156
+ * observability to on-demand session-log scans); this path exists for
157
+ * backward compatibility and store-driven tests. Production prefers the
158
+ * live in-memory recorder view (lib/trigger.js) so the nudge is not starved.
159
+ */
160
+ export function usageBalanceView(store, workspace) {
141
161
  const rows = store.usageRows(workspace);
142
- if (rows.length === 0) return "";
162
+ if (rows.length === 0) return null;
143
163
  const recent = rows.slice(-14);
144
164
  const MEM = ["engram_store", "engram_recall", "engram_detail", "loom_store", "loom_recall", "loom_detail"];
145
165
  const ESR = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
@@ -150,44 +170,276 @@ export function escalationHint(store, workspace) {
150
170
  for (const k of MEM) memCalls += c[k] ?? 0;
151
171
  for (const k of ESR) esrCalls += c[k] ?? 0;
152
172
  }
153
- // No data or too little signal — lecturing on <3 memory ops is noise.
154
- if (memCalls < 3) return "";
155
- const ratio = esrCalls / (memCalls + esrCalls);
156
- // Healthy once esr exceeds ~half the memory-ops volume; otherwise nudge.
157
- if (ratio >= 0.34) return "";
158
- return `escalate: last ${recent.length}d ${memCalls} mem ops vs ${esrCalls} esr calls (${Math.round(ratio * 100)}%) — multi-step work → esr_task now, recurring objects → esr_node, related things → esr_link, and close with evidence (esr_close)`;
173
+ return { memCalls, esrCalls, days: recent.length };
174
+ }
175
+
176
+ /**
177
+ * Legacy entry point (store-driven escalation nudge). Kept for callers/tests
178
+ * that predate the decision-point recorder; prefer `esrBalanceHint` with a
179
+ * live balance view.
180
+ */
181
+ export function escalationHint(store, workspace) {
182
+ return esrBalanceHint(usageBalanceView(store, workspace));
183
+ }
184
+
185
+ /**
186
+ * The three ESR closure evidence gates for a task. Null/empty counts as a gap.
187
+ */
188
+ export function evidenceGaps(t) {
189
+ const gaps = [];
190
+ if (t.artifact === null || t.artifact === "") gaps.push("artifact");
191
+ if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
192
+ if (t.memoryRefs.length === 0) gaps.push("memory_ref");
193
+ return gaps;
159
194
  }
160
195
 
161
196
  /**
162
- * Render the [ESR] block — active tasks with closure gaps. Only appears when
163
- * at least one task exists. "STABLE" tasks are folded to a count line to save
164
- * tokens.
197
+ * "What should I do with this task next" sort rank — READY-to-close first
198
+ * (evidence complete → esr_close is the highest-value next move), then claimed
199
+ * in-progress, then other active, then drafts.
165
200
  */
166
- export function renderEsr(store, workspace, config) {
201
+ function taskNextRank(t) {
202
+ if (t.state === "stable") return 10;
203
+ if (evidenceGaps(t).length === 0) return 0;
204
+ if (t.assignee) return 1;
205
+ if (t.state === "active") return 2;
206
+ if (t.state === "draft") return 3;
207
+ return 4;
208
+ }
209
+
210
+ /**
211
+ * Order active tasks so the model can act top-down without re-scanning.
212
+ *
213
+ * The final tiebreak is the task id (lexicographic), NEVER updatedAt: a
214
+ * timestamp in the sort would make the frozen per-session snapshot drift
215
+ * between turns and shatter the prefix cache. pi-esr's rule 2 — timestamps
216
+ * excluded from context — applied verbatim.
217
+ */
218
+ export function sortActiveForNext(tasks) {
219
+ return [...tasks].sort((a, b) => {
220
+ const ra = taskNextRank(a);
221
+ const rb = taskNextRank(b);
222
+ if (ra !== rb) return ra - rb;
223
+ const ga = evidenceGaps(a).length;
224
+ const gb = evidenceGaps(b).length;
225
+ if (ga !== gb) return ga - gb;
226
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Recurring-failure candidates (P0-B): error memories that earned
232
+ * `hits >= minErrorHits` through repeat-failure revival — durable breakage,
233
+ * the strongest "turn this into a root-cause task" signal. Most-hit first.
234
+ */
235
+ export function selectRootCauseCandidates(store, workspace, config) {
236
+ const min = (config?.minErrorHits ?? 2) | 0;
237
+ if (min < 1) return [];
238
+ return store
239
+ .listMemories(workspace, 200)
240
+ .filter((m) => m.kind === "error" && (m.hits ?? 0) >= min)
241
+ .sort((a, b) => (b.hits ?? 0) - (a.hits ?? 0) || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
242
+ .map((m) => ({ id: m.id, text: String(m.text), hits: m.hits ?? 0 }));
243
+ }
244
+
245
+ /**
246
+ * Stale-task candidates (P3): active tasks that have not been touched for
247
+ * `staleTaskDays` (default 14) — close with evidence or drop the intent.
248
+ */
249
+ export function selectStaleCandidates(tasks, config) {
250
+ const days = (config?.staleTaskDays ?? 14) | 0;
251
+ if (days < 1) return [];
252
+ const cutoff = Date.now() - days * 86_400_000;
253
+ return tasks
254
+ .filter((t) => (t.updatedAt ?? t.createdAt ?? 0) < cutoff)
255
+ .map((t) => ({ id: t.id, name: t.name, updatedAt: t.updatedAt ?? t.createdAt, createdAt: t.createdAt }));
256
+ }
257
+
258
+ /**
259
+ * Static ESR operating methodology (pi-esr rule 1: the system prompt never
260
+ * changes at runtime — this text is process-static and byte-identical every
261
+ * turn). Teaches WHEN to create / promote / close so the model no longer
262
+ * depends on runtime nudges to remember the protocol.
263
+ */
264
+ export function esrMethodology() {
265
+ return [
266
+ "ESR(工程状态)操作协议 —— 多步工作建任务、用证据闭环:",
267
+ " 1. 动工前:esr_ready 看可认领工作;esr_status 拿实时状态(本块是会话开始快照,不会自动刷新)。",
268
+ " 2. 多步工作 → esr_task(name=\"…\", entity=…) 建任务(draft 起步);重复对象 → esr_node;关系 → esr_link。",
269
+ " 3. 动工 → esr_claim(draft → active)。",
270
+ " 4. 收工 → esr_close(artifact + evaluation + memory_ref 三证齐才算数;不齐的活继续挂着,别硬关)。",
271
+ " state 是唯一真相:拿不准 state 就 call esr_status。",
272
+ ].join("\n");
273
+ }
274
+
275
+ /**
276
+ * Build the DETERMINISTIC per-session [ESR] snapshot for a workspace.
277
+ *
278
+ * Pure derivation from the store — no recorder, no timestamps, no hint lines.
279
+ * Same store snapshot → byte-identical output, so the section provider can
280
+ * render it ONCE per session and freeze it (prefix-cache stable, pi-esr rule
281
+ * 2: injected once, never auto-refreshed; the model PULLS live state).
282
+ *
283
+ * @param opts {{ sessionId?: string }} — reserved for future live-tail
284
+ * wording; the block itself is identical either way.
285
+ */
286
+ export function buildEsrSnapshot(store, workspace, config, opts = {}) {
167
287
  const active = store.listTasks(workspace, { includeStable: false });
168
288
  const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
169
- const hint = escalationHint(store, workspace);
289
+ const draft = active.filter((t) => t.state === "draft").length;
290
+ const ordered = sortActiveForNext(active);
291
+
170
292
  if (active.length === 0 && stable.length === 0) {
171
293
  // Keep the mechanism visible even when idle: one compact line teaches the
172
294
  // model that multi-step work earns an esr_task closed with real evidence.
173
- const base = "[ESR] no open tasks — BE PROACTIVE: multi-step work gets a task now (esr_task); recurring domain objects get a node (esr_node); related things get a link (esr_link). Close tasks with real evidence (esr_close)";
174
- return hint === "" ? base : `${base}\n${hint}`;
295
+ return [
296
+ "[ESR] no open tasks — BE PROACTIVE: multi-step work gets a task now (esr_task); recurring domain objects get a node (esr_node); related things get a link (esr_link). Close tasks with real evidence (esr_close)",
297
+ "snapshot from session start — WILL NOT auto-refresh; call esr_status for live state",
298
+ ].join("\n");
175
299
  }
176
300
 
177
301
  const lines = [];
178
- lines.push(`[ESR] tasks: ${active.length} active / ${stable.length} stable`);
179
- for (const t of active.slice(0, 8)) lines.push(`- ${t.id.slice(0, 6)}: ${escapeLt(truncate(t.name, 60))} — ${t.state.toUpperCase()}${taskGapSuffix(t)}`);
302
+ lines.push(`[ESR] tasks: ${ordered.filter((t) => t.state !== "draft").length} active / ${stable.length} stable${draft > 0 ? ` / ${draft} draft` : ""}`);
303
+ const firstReady = ordered.find((t) => evidenceGaps(t).length === 0);
304
+ if (firstReady !== void 0) {
305
+ lines.push(`next: esr_close ${firstReady.id.slice(0, 6)} — ${escapeLt(truncate(firstReady.name, 40))} (all evidence present)`);
306
+ }
307
+ for (const t of ordered.slice(0, 8)) lines.push(`- ${t.id.slice(0, 6)}: ${escapeLt(truncate(t.name, 60))} — ${t.state.toUpperCase()}${taskGapSuffix(t)}`);
180
308
  if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
181
309
  if (stable.length > 0) lines.push(`- closed: ${stable.slice(0, 6).map((t) => `${t.id.slice(0, 6)} (${truncate(t.name, 28)})`).join(" · ")}${stable.length > 6 ? ` +${stable.length - 6}` : ""}`);
182
- if (hint !== "") lines.push(hint);
310
+ lines.push("snapshot from session start — WILL NOT auto-refresh; call esr_status for live state");
311
+ return lines.join("\n");
312
+ }
313
+
314
+ /**
315
+ * The derived decision-point hint lines, assembled ONLY on the pull path
316
+ * (`esr_status`) — never in the frozen [ESR] block. Recorder-fed and
317
+ * one-shot per session; P4 exposure is counted here (real return path), while
318
+ * the GUI preview (no recorder/session) yields [] and counts nothing.
319
+ *
320
+ * @returns {Array<{ kind: string, line: string }>}
321
+ */
322
+ export function esrHintLines(store, workspace, config, opts = {}) {
323
+ const recorder = opts?.recorder;
324
+ const sessionId = opts?.sessionId;
325
+ const live = recorder !== void 0 && sessionId !== void 0;
326
+ if (!live) return [];
327
+ const active = store.listTasks(workspace, { includeStable: false });
328
+ const ordered = sortActiveForNext(active);
329
+ const out = [];
330
+ const promote = recorder.promoteHint(sessionId, workspace, active.length);
331
+ if (promote !== "") {
332
+ out.push({ kind: "promote", line: promote });
333
+ recorder.emitHint("promote", workspace, sessionId);
334
+ }
335
+ const rootCause = recorder.errorHint(sessionId, workspace, selectRootCauseCandidates(store, workspace, config));
336
+ if (rootCause !== "") {
337
+ out.push({ kind: "rootcause", line: rootCause });
338
+ recorder.emitHint("rootcause", workspace, sessionId);
339
+ }
340
+ const closure = recorder.closureHint(sessionId, workspace, ordered.filter((t) => evidenceGaps(t).length === 0));
341
+ if (closure !== "") {
342
+ out.push({ kind: "close", line: closure });
343
+ recorder.emitHint("close", workspace, sessionId);
344
+ }
345
+ const stale = recorder.staleHint(sessionId, workspace, selectStaleCandidates(active, config));
346
+ if (stale !== "") {
347
+ out.push({ kind: "stale", line: stale });
348
+ recorder.emitHint("stale", workspace, sessionId);
349
+ }
350
+ // Live in-memory balance feeds the escalation reminder (falls back to the
351
+ // legacy usage-table view for store-driven callers without a recorder).
352
+ const balance = recorder !== void 0 ? recorder.recentBalance(workspace) : usageBalanceView(store, workspace);
353
+ const escalate = esrBalanceHint(balance);
354
+ if (escalate !== "") {
355
+ out.push({ kind: "escalate", line: escalate });
356
+ recorder.emitHint("escalate", workspace, sessionId);
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /**
362
+ * Render the frozen [ESR] snapshot (backward-compatible face of
363
+ * `buildEsrSnapshot`). ALWAYS hint-free: the section provider renders this
364
+ * once per session and freezes it; decision-point lines live on the
365
+ * `esr_status` pull path via `esrHintLines`.
366
+ */
367
+ export function renderEsr(store, workspace, config, opts = {}) {
368
+ return buildEsrSnapshot(store, workspace, config, opts);
369
+ }
370
+
371
+ /** Stable display order for accumulated this-session actionables. */
372
+ export const ESR_HINT_ORDER = ["promote", "rootcause", "close", "stale", "escalate"];
373
+
374
+ /**
375
+ * Build the per-session [ESR] block the model actually sees: a FROZEN
376
+ * deterministic snapshot (never changes mid-session, pi-esr rule 2) PLUS the
377
+ * session's monotonically accumulated decision-point actionables.
378
+ *
379
+ * An actionable is APPENDED exactly once, the first time it matures (the
380
+ * recorder's per-(session,kind) suppression makes esrHintLines return it only
381
+ * that once); after appending it stays for the whole session (never removed,
382
+ * even if the underlying condition later clears) — so the prefix changes only
383
+ * when genuinely new decision-point info appears, never per-turn. Snapshot
384
+ * stays pure (no timestamps, no recorder) and live state is still pulled via
385
+ * `esr_status`.
386
+ *
387
+ * @param opts {{
388
+ * recorder?: import("./trigger.js").TriggerRecorder,
389
+ * sessionId?: string,
390
+ * holder?: { actionables: Map<string,string> }, // per-session accumulator
391
+ * snapshot?: string, // pre-frozen snapshot (host passes the session-start one)
392
+ * }}
393
+ */
394
+ export function buildFrozenEsrBlock(store, workspace, config, opts = {}) {
395
+ // Host passes the session-start snapshot (frozen, pi-esr rule 2); callers
396
+ // without one get a fresh deterministic derivation (pure functions/tests).
397
+ const snapshot = typeof opts?.snapshot === "string" ? opts.snapshot : buildEsrSnapshot(store, workspace, config, opts);
398
+ const holder = opts?.holder;
399
+ const recorder = opts?.recorder;
400
+ const sessionId = opts?.sessionId;
401
+ if (holder !== void 0 && recorder !== void 0 && sessionId !== void 0) {
402
+ const fresh = esrHintLines(store, workspace, config, { recorder, sessionId });
403
+ for (const h of fresh) holder.actionables.set(h.kind, h.line);
404
+ if (holder.actionables.size > 0) {
405
+ const lines = [snapshot, "# this-session actionables (frozen)"];
406
+ for (const kind of ESR_HINT_ORDER) {
407
+ const line = holder.actionables.get(kind);
408
+ if (line !== void 0) lines.push(line);
409
+ }
410
+ return lines.join("\n");
411
+ }
412
+ }
413
+ return snapshot;
414
+ }
415
+
416
+ /**
417
+ * Compose an `esr_status` response (the PULL surface): the deterministic ESR
418
+ * snapshot + revision, plus the derived decision-point hints. When the caller
419
+ * passes the current revision via `sinceRevision`, the state view collapses
420
+ * to a short "unchanged" line (pi-esr rule 3) while actionables still surface
421
+ * — they are the volatile part (balance / stale window) that motivates a pull.
422
+ *
423
+ * @param opts {{ recorder?, sessionId?, sinceRevision? }}
424
+ */
425
+ export function buildEsrStatusView(store, workspace, config, opts = {}) {
426
+ const revision = store.esrFingerprint(workspace);
427
+ const hints = esrHintLines(store, workspace, config, opts);
428
+ const hintBlock = hints.length > 0 ? ["── actionables ──", ...hints.map((h) => h.line)].join("\n") : "";
429
+ const since = opts?.sinceRevision;
430
+ if (since !== void 0 && since !== null && since !== "" && since === revision) {
431
+ const lines = ["[ESR_STATUS]", `ESR state unchanged since revision ${since}.`, `ESR revision: ${revision}`];
432
+ if (hintBlock !== "") lines.push("", hintBlock);
433
+ return lines.join("\n");
434
+ }
435
+ const snapshot = buildEsrSnapshot(store, workspace, config, opts);
436
+ const lines = [snapshot, `ESR revision: ${revision}`];
437
+ if (hintBlock !== "") lines.push("", hintBlock);
183
438
  return lines.join("\n");
184
439
  }
185
440
 
186
441
  function taskGapSuffix(t) {
187
442
  if (t.state === "stable") return "";
188
- const gaps = [];
189
- if (t.artifact === null || t.artifact === "") gaps.push("artifact");
190
- if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
191
- if (t.memoryRefs.length === 0) gaps.push("memory_ref");
443
+ const gaps = evidenceGaps(t);
192
444
  return gaps.length === 0 ? " · READY to close" : ` · gap: ${gaps.join(", ")}`;
193
445
  }
package/lib/index.js CHANGED
@@ -20,9 +20,12 @@ import { resolve as resolvePath } from "node:path";
20
20
  import { openEngramDomain } from "./store.js";
21
21
  import { registerTools } from "./tools.js";
22
22
  import { makeCaptureHandler } from "./capture.js";
23
- import { renderIndex, renderEsr } from "./index-block.js";
23
+ import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
24
+ import { makeTriggerRecorder } from "./trigger.js";
24
25
  import { installEngramSettings } from "./settings.js";
25
26
  import { makeEngramRoutes } from "./api.js";
27
+ import { loadCompactionEngine, collectPointerContext } from "./context-gc.js";
28
+ import { workspaceKey as wk } from "./util.js";
26
29
 
27
30
  /** Stable cordis plugin name. */
28
31
  export const name = "engram";
@@ -56,6 +59,14 @@ const DEFAULTS = {
56
59
  gcEnabled: true,
57
60
  gcIntervalHours: 24,
58
61
  gcStableRetentionDays: 120,
62
+ // Context GC — the auto-GC that REPLACES DSH's built-in lossy LLM-summary
63
+ // compaction (pi-esr CONTEXT_GC): mechanical eviction + re-fetch pointers,
64
+ // NOT the memory-panel GC above. `gcReplacesCompaction` off → the host keeps
65
+ // its default compaction backend; `gcNarrative` off → fully mechanical.
66
+ gcReplacesCompaction: true,
67
+ gcNarrative: true,
68
+ gcNarrativeMaxTokens: 1024,
69
+ gcNarrativeMaxChars: 4000,
59
70
  // GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
60
71
  // (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
61
72
  trustedHosts: [],
@@ -65,7 +76,18 @@ const DEFAULTS = {
65
76
  verifyArtifact: true,
66
77
  // prompt section order (before the persona/tools bands per DSH convention)
67
78
  engramIndexOrder: 40,
79
+ esrMethodOrder: 40.5, // static ESR protocol section (byte-identical every turn)
68
80
  esrOrder: 41,
81
+ // decision-point triggers (P0-A): promote a session plan to an ESR task
82
+ // when the pending-todo count is at least minTodosForPromote and exceeds
83
+ // active ESR tasks × todoPromoteRatio (default: pending > active).
84
+ minTodosForPromote: 2,
85
+ todoPromoteRatio: 1,
86
+ // P0-B: an error memory becomes a root-cause task candidate at this many
87
+ // repeat-failure hits.
88
+ minErrorHits: 2,
89
+ // P3: an active task with no update for this many days is surfaced as stale.
90
+ staleTaskDays: 14,
69
91
  };
70
92
 
71
93
  function resolveConfig(config) {
@@ -127,10 +149,15 @@ export function apply(ctx, config) {
127
149
  return domainPromise;
128
150
  };
129
151
 
152
+ // Decision-point triggers (P0-A + P1): live mem-vs-esr balance + the
153
+ // todo_write promote hook. Process-local; fed by a tools/result listener.
154
+ const recorder = makeTriggerRecorder(resolved);
155
+
130
156
  const service = {
131
157
  config: resolved,
132
158
  getDomain,
133
159
  openedDomain: () => opened,
160
+ recorder,
134
161
  ensureDomain: () => {
135
162
  getDomain().catch(() => {});
136
163
  },
@@ -149,7 +176,7 @@ export function apply(ctx, config) {
149
176
  return "";
150
177
  }
151
178
  },
152
- /** Recompute the exact [ESR] block for a workspace (GUI inject preview). */
179
+ /** Recompute the exact [ESR] snapshot for a workspace (GUI inject preview). */
153
180
  renderEsrBlock(workspace) {
154
181
  if (opened === void 0) return "";
155
182
  try {
@@ -204,8 +231,10 @@ export function apply(ctx, config) {
204
231
  };
205
232
  });
206
233
 
207
- // Per-session frozen blocks: render once, then reuse — prefix stability is
208
- // the point (KV cache). New session (or resumed reload) object → fresh block.
234
+ // [ENGRAM] block: render once, then reuse — prefix stability is the point
235
+ // (KV cache). New session (or resumed reload) object → fresh block. The
236
+ // [ESR] block below follows the same frozen-snapshot rule but additionally
237
+ // accumulates this-session actionables (see esrBlockFor).
209
238
  const sessionCache = new WeakMap();
210
239
  const blockFor = (agent) => {
211
240
  if (agent === void 0) return "";
@@ -230,6 +259,54 @@ export function apply(ctx, config) {
230
259
  return block;
231
260
  };
232
261
 
262
+ // [ESR] block = FROZEN snapshot + this-session's monotonically accumulated
263
+ // actionables (promote/root-cause/close/stale/escalate).
264
+ // - The snapshot is rendered ONCE at the first assembly of the session and
265
+ // cached per session (pi-esr rule 2: injected once, never
266
+ // auto-refreshed) — tasks created/updated mid-session surface via
267
+ // `esr_status`, not by re-rendering this block.
268
+ // - An actionable is APPENDED exactly once, the moment it first matures
269
+ // (recorder-fed, one-shot via esrHintLines), then stays frozen — the
270
+ // prefix therefore changes only when genuinely new decision-point info
271
+ // exists, never per-turn.
272
+ const esrSnapshotCache = new WeakMap(); // session -> frozen snapshot text
273
+ const esrSessionHolders = new WeakMap(); // session -> { actionables: Map<kind,line> }
274
+ const esrBlockFor = (agent) => {
275
+ if (agent === void 0) return "";
276
+ const session = agent.session;
277
+ if (session === void 0) return "";
278
+ const cwd = session.header?.cwd;
279
+ if (cwd === void 0 || cwd.length === 0) return "";
280
+ if (opened === void 0) return "";
281
+ let snapshot = esrSnapshotCache.get(session);
282
+ if (snapshot === void 0) {
283
+ try {
284
+ snapshot = buildEsrSnapshot(opened, resolvePath(cwd), resolved, { sessionId: session.id });
285
+ } catch (error) {
286
+ log?.warn?.(`engram esr render failed: ${String(error)}`);
287
+ return "";
288
+ }
289
+ esrSnapshotCache.set(session, snapshot);
290
+ }
291
+ let holder = esrSessionHolders.get(session);
292
+ if (holder === void 0) {
293
+ holder = { actionables: new Map() };
294
+ esrSessionHolders.set(session, holder);
295
+ }
296
+ let block = "";
297
+ try {
298
+ block = buildFrozenEsrBlock(opened, resolvePath(cwd), resolved, {
299
+ recorder,
300
+ sessionId: session.id,
301
+ holder,
302
+ snapshot,
303
+ });
304
+ } catch (error) {
305
+ log?.warn?.(`engram esr render failed: ${String(error)}`);
306
+ }
307
+ return block;
308
+ };
309
+
233
310
  ctx.effect(() => {
234
311
  const disposers = [];
235
312
 
@@ -254,27 +331,34 @@ export function apply(ctx, config) {
254
331
  });
255
332
  disposers.push(() => indexDispose());
256
333
 
334
+ // Static ESR protocol (pi-esr rule 1): byte-identical every turn, so
335
+ // the model never depends on runtime nudges to know WHEN to use ESR.
336
+ const esrMethodDispose = systemPrompt.section({
337
+ name: "engram:esr-method",
338
+ order: resolved.esrMethodOrder,
339
+ text: () => esrMethodology(),
340
+ });
341
+ disposers.push(() => esrMethodDispose());
342
+
257
343
  const esrDispose = systemPrompt.section({
258
344
  name: "engram:esr",
259
345
  order: resolved.esrOrder,
260
346
  text: (assemblyContext) => {
261
347
  const agent = assemblyContext?.agent;
262
348
  if (agent === void 0) return "";
263
- const session = agent.session;
264
- if (session === void 0) return "";
265
- const cwd = session.header?.cwd;
266
- if (cwd === void 0 || cwd.length === 0) return "";
267
- if (opened === void 0) return "";
268
- try {
269
- return renderEsr(opened, resolvePath(cwd), resolved);
270
- } catch {
271
- return "";
272
- }
349
+ return esrBlockFor(agent);
273
350
  },
274
351
  });
275
352
  disposers.push(() => esrDispose());
276
353
  }
277
354
 
355
+ // decision-point triggers: always on (independent of autoCapture) —
356
+ // feeds the live mem-vs-esr balance and the todo_write promote hint.
357
+ const disposeTrigger = ctx.on("tools/result", (exec, result) => {
358
+ recorder.handle(exec, result);
359
+ });
360
+ disposers.push(disposeTrigger);
361
+
278
362
  // zero-LLM auto-capture
279
363
  if (resolved.autoCapture) {
280
364
  // The capture handler needs a storeMemory face, but the domain opens
@@ -322,6 +406,8 @@ export function apply(ctx, config) {
322
406
  }
323
407
  disposers.push(gcDispose);
324
408
 
409
+
410
+
325
411
  return async () => {
326
412
  for (const dispose of disposers) {
327
413
  try {
package/lib/settings.js CHANGED
@@ -32,6 +32,8 @@ export const SETTINGS_KEYS = [
32
32
  "maxMemoriesPerWorkspace",
33
33
  "gcEnabled",
34
34
  "gcStableRetentionDays",
35
+ "gcReplacesCompaction",
36
+ "gcNarrative",
35
37
  "trustedHosts",
36
38
  ];
37
39
 
@@ -59,6 +61,8 @@ export function makeSettingsSchema(defaults) {
59
61
  maxMemoriesPerWorkspace: z.natural().max(10000).default(defaults.maxMemoriesPerWorkspace),
60
62
  gcEnabled: z.boolean().default(defaults.gcEnabled),
61
63
  gcStableRetentionDays: z.natural().max(3650).default(defaults.gcStableRetentionDays),
64
+ gcReplacesCompaction: z.boolean().default(defaults.gcReplacesCompaction),
65
+ gcNarrative: z.boolean().default(defaults.gcNarrative),
62
66
  verifyArtifact: z.boolean().default(defaults.verifyArtifact).description("ESR 闭环时校验 artifact 路径真实存在"),
63
67
  // Security knob, not rendered in the card: hostnames allowed past the
64
68
  // loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
package/lib/store.js CHANGED
@@ -490,6 +490,21 @@ export async function openEngramDomain(facility) {
490
490
  .filter((t) => t.workspace === workspace && notArchivedTask(t) && (includeStable || t.state !== "stable"))
491
491
  .sort(byRecency);
492
492
  },
493
+ /**
494
+ * Deterministic fingerprint of the ESR task surface (what `esr_status` /
495
+ * the [ESR] snapshot render): id, state, evidence fields, assignee — NOT
496
+ * timestamps (pi-esr rule 2: timestamps excluded from context). Used as
497
+ * the `revision` token for incremental pulls: same tasks → same revision
498
+ * → "unchanged" short response.
499
+ */
500
+ esrFingerprint(workspace) {
501
+ const parts = [...tasks.entries()]
502
+ .map(([, t]) => t)
503
+ .filter((t) => t.workspace === workspace && notArchivedTask(t))
504
+ .map((t) => `${t.id}|${t.state}|${t.artifact ?? ""}|${t.evaluation ?? ""}|${(t.memoryRefs ?? []).join(",")}|${t.assignee ?? ""}`)
505
+ .sort();
506
+ return hashText(`esr:${parts.join("\n")}`);
507
+ },
493
508
  async putTask(task) {
494
509
  if ([...task.name].length > 200) throw new EngramError("ENGRAM_CAP_EXCEEDED", "task name is over 200 characters");
495
510
  if (task.id !== task.id) throw new EngramError("ENGRAM_INVALID_ARGS", "task id mismatch");
@@ -776,6 +791,22 @@ export async function openEngramDomain(facility) {
776
791
  addLink(link) {
777
792
  return links.put(link.id, link);
778
793
  },
794
+ /** Idempotent variant: skip when the same (source, relation, target) edge
795
+ * already exists in the workspace — used by auto-wiring paths so repeated
796
+ * calls never duplicate a relation. */
797
+ async addLinkOnce(link) {
798
+ const dup = [...links.entries()]
799
+ .map(([, l]) => l)
800
+ .some(
801
+ (l) =>
802
+ l.workspace === link.workspace &&
803
+ l.source === link.source &&
804
+ l.target === link.target &&
805
+ (l.relation ?? "") === (link.relation ?? ""),
806
+ );
807
+ if (!dup) await links.put(link.id, link);
808
+ return !dup;
809
+ },
779
810
  linksFor(workspace, entity) {
780
811
  return [...links.entries()]
781
812
  .map(([, l]) => l)