dsh-engram 0.3.2 → 0.3.4

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.
@@ -207,7 +207,14 @@ function taskNextRank(t) {
207
207
  return 4;
208
208
  }
209
209
 
210
- /** Order active tasks so the model can act top-down without re-scanning. */
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
+ */
211
218
  export function sortActiveForNext(tasks) {
212
219
  return [...tasks].sort((a, b) => {
213
220
  const ra = taskNextRank(a);
@@ -216,7 +223,7 @@ export function sortActiveForNext(tasks) {
216
223
  const ga = evidenceGaps(a).length;
217
224
  const gb = evidenceGaps(b).length;
218
225
  if (ga !== gb) return ga - gb;
219
- return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
226
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
220
227
  });
221
228
  }
222
229
 
@@ -249,50 +256,50 @@ export function selectStaleCandidates(tasks, config) {
249
256
  }
250
257
 
251
258
  /**
252
- * Render the [ESR] block — active tasks with closure gaps. Only appears when
253
- * at least one task exists. "STABLE" tasks are folded to a count line to save
254
- * tokens.
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.
255
277
  *
256
- * @param opts {{
257
- * recorder?: import("./trigger.js").TriggerRecorder, // decision-point recorder (P0-A–P3)
258
- * sessionId?: string, // live session for the hints
259
- * }}
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.
260
285
  */
261
- export function renderEsr(store, workspace, config, opts = {}) {
262
- const recorder = opts?.recorder;
263
- const sessionId = opts?.sessionId;
264
- const live = recorder !== void 0 && sessionId !== void 0;
286
+ export function buildEsrSnapshot(store, workspace, config, opts = {}) {
265
287
  const active = store.listTasks(workspace, { includeStable: false });
266
288
  const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
289
+ const draft = active.filter((t) => t.state === "draft").length;
267
290
  const ordered = sortActiveForNext(active);
268
- // Prefer the live in-memory balance (recorder); fall back to the legacy
269
- // usage-table view so store-driven callers/tests keep working unchanged.
270
- const balance = recorder !== void 0 ? recorder.recentBalance(workspace) : usageBalanceView(store, workspace);
271
- const hint = esrBalanceHint(balance);
272
- const promote = live ? recorder.promoteHint(sessionId, workspace, active.length) : "";
273
- const rootCause = live ? recorder.errorHint(sessionId, workspace, selectRootCauseCandidates(store, workspace, config)) : "";
274
- const closure = live ? recorder.closureHint(sessionId, workspace, ordered.filter((t) => evidenceGaps(t).length === 0)) : "";
275
- const stale = live ? recorder.staleHint(sessionId, workspace, selectStaleCandidates(active, config)) : "";
276
- // P4: count only what is actually injected for a live session (never the
277
- // GUI preview, which renders the same lines without a sessionId).
278
- if (live) {
279
- if (promote !== "") recorder.emitHint("promote", workspace, sessionId);
280
- if (rootCause !== "") recorder.emitHint("rootcause", workspace, sessionId);
281
- if (closure !== "") recorder.emitHint("close", workspace, sessionId);
282
- if (stale !== "") recorder.emitHint("stale", workspace, sessionId);
283
- if (hint !== "") recorder.emitHint("escalate", workspace, sessionId);
284
- }
285
291
 
286
292
  if (active.length === 0 && stable.length === 0) {
287
293
  // Keep the mechanism visible even when idle: one compact line teaches the
288
294
  // model that multi-step work earns an esr_task closed with real evidence.
289
- 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)";
290
- const rest = [promote, rootCause, hint].filter((l) => l !== "");
291
- return rest.length === 0 ? base : `${base}\n${rest.join("\n")}`;
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");
292
299
  }
293
300
 
294
301
  const lines = [];
295
- lines.push(`[ESR] tasks: ${active.length} active / ${stable.length} stable`);
302
+ lines.push(`[ESR] tasks: ${ordered.filter((t) => t.state !== "draft").length} active / ${stable.length} stable${draft > 0 ? ` / ${draft} draft` : ""}`);
296
303
  const firstReady = ordered.find((t) => evidenceGaps(t).length === 0);
297
304
  if (firstReady !== void 0) {
298
305
  lines.push(`next: esr_close ${firstReady.id.slice(0, 6)} — ${escapeLt(truncate(firstReady.name, 40))} (all evidence present)`);
@@ -300,11 +307,134 @@ export function renderEsr(store, workspace, config, opts = {}) {
300
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)}`);
301
308
  if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
302
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}` : ""}`);
303
- if (promote !== "") lines.push(promote);
304
- if (rootCause !== "") lines.push(rootCause);
305
- if (closure !== "") lines.push(closure);
306
- if (stale !== "") lines.push(stale);
307
- 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);
308
438
  return lines.join("\n");
309
439
  }
310
440
 
package/lib/index.js CHANGED
@@ -20,10 +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
24
  import { makeTriggerRecorder } from "./trigger.js";
25
25
  import { installEngramSettings } from "./settings.js";
26
26
  import { makeEngramRoutes } from "./api.js";
27
+ import { loadCompactionEngine, collectPointerContext } from "./context-gc.js";
28
+ import { workspaceKey as wk } from "./util.js";
27
29
 
28
30
  /** Stable cordis plugin name. */
29
31
  export const name = "engram";
@@ -57,6 +59,14 @@ const DEFAULTS = {
57
59
  gcEnabled: true,
58
60
  gcIntervalHours: 24,
59
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,
60
70
  // GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
61
71
  // (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
62
72
  trustedHosts: [],
@@ -66,6 +76,7 @@ const DEFAULTS = {
66
76
  verifyArtifact: true,
67
77
  // prompt section order (before the persona/tools bands per DSH convention)
68
78
  engramIndexOrder: 40,
79
+ esrMethodOrder: 40.5, // static ESR protocol section (byte-identical every turn)
69
80
  esrOrder: 41,
70
81
  // decision-point triggers (P0-A): promote a session plan to an ESR task
71
82
  // when the pending-todo count is at least minTodosForPromote and exceeds
@@ -165,11 +176,11 @@ export function apply(ctx, config) {
165
176
  return "";
166
177
  }
167
178
  },
168
- /** Recompute the exact [ESR] block for a workspace (GUI inject preview). */
179
+ /** Recompute the exact [ESR] snapshot for a workspace (GUI inject preview). */
169
180
  renderEsrBlock(workspace) {
170
181
  if (opened === void 0) return "";
171
182
  try {
172
- return renderEsr(opened, workspace, resolved, { recorder });
183
+ return renderEsr(opened, workspace, resolved);
173
184
  } catch {
174
185
  return "";
175
186
  }
@@ -220,8 +231,10 @@ export function apply(ctx, config) {
220
231
  };
221
232
  });
222
233
 
223
- // Per-session frozen blocks: render once, then reuse — prefix stability is
224
- // 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).
225
238
  const sessionCache = new WeakMap();
226
239
  const blockFor = (agent) => {
227
240
  if (agent === void 0) return "";
@@ -246,6 +259,54 @@ export function apply(ctx, config) {
246
259
  return block;
247
260
  };
248
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
+
249
310
  ctx.effect(() => {
250
311
  const disposers = [];
251
312
 
@@ -270,25 +331,22 @@ export function apply(ctx, config) {
270
331
  });
271
332
  disposers.push(() => indexDispose());
272
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
+
273
343
  const esrDispose = systemPrompt.section({
274
344
  name: "engram:esr",
275
345
  order: resolved.esrOrder,
276
346
  text: (assemblyContext) => {
277
347
  const agent = assemblyContext?.agent;
278
348
  if (agent === void 0) return "";
279
- const session = agent.session;
280
- if (session === void 0) return "";
281
- const cwd = session.header?.cwd;
282
- if (cwd === void 0 || cwd.length === 0) return "";
283
- if (opened === void 0) return "";
284
- try {
285
- return renderEsr(opened, resolvePath(cwd), resolved, {
286
- recorder,
287
- sessionId: session.id,
288
- });
289
- } catch {
290
- return "";
291
- }
349
+ return esrBlockFor(agent);
292
350
  },
293
351
  });
294
352
  disposers.push(() => esrDispose());
@@ -348,6 +406,8 @@ export function apply(ctx, config) {
348
406
  }
349
407
  disposers.push(gcDispose);
350
408
 
409
+
410
+
351
411
  return async () => {
352
412
  for (const dispose of disposers) {
353
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");
package/lib/tools.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * tool schemas cost tokens in every request):
6
6
  * engram_store / engram_recall / engram_detail — the engram memory surface
7
7
  * esr_task / esr_node / esr_close / esr_link — the ESR-lite evidence surface
8
+ * esr_ready / esr_status — the pull surface (live state + hints)
8
9
  * esr_gc — pi-esr memory garbage collection
9
10
  *
10
11
  * Every call is recorded into the per-(workspace, day) usage rollup so the
@@ -16,6 +17,7 @@
16
17
 
17
18
  import { z } from "zod";
18
19
  import { EngramError, hasCode } from "./store.js";
20
+ import { buildEsrStatusView } from "./index-block.js";
19
21
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
20
22
 
21
23
  const KINDS = [
@@ -510,6 +512,33 @@ ${hint}` : ""}`;
510
512
  },
511
513
  }),
512
514
 
515
+ // ── esr_status ──────────────────────────────────────────────────
516
+ registerTool({
517
+ name: "esr_status",
518
+ description:
519
+ "Pull the live ESR state (deterministic snapshot + revision) plus decision-point hints (promote / root-cause / close / stale / escalate). Pass since_revision to skip unchanged state — returns a short response when nothing changed. The [ESR] block in the prompt is a session-start snapshot that never auto-refreshes; call this for the current truth.",
520
+ parameters: {
521
+ type: "object",
522
+ properties: {
523
+ workspace: { type: "string", description: "Override the workspace key (default: current session cwd)" },
524
+ since_revision: { type: "string", description: "Last-seen ESR revision (from a previous esr_status response); unchanged state returns a short response" },
525
+ },
526
+ },
527
+ output: TEXT_OUTPUT,
528
+ timeoutMs: 20000,
529
+ execute: async (raw, exec) => {
530
+ const args = parse(z.object({ workspace: z.string().optional(), since_revision: z.string().optional() }), raw, "esr_status");
531
+ const caller = callerOf(exec);
532
+ const domain = await requireDomain(service);
533
+ const ws = wk(args.workspace ?? caller.cwd);
534
+ return buildEsrStatusView(domain, ws, service.config, {
535
+ recorder: service.recorder,
536
+ sessionId: caller.sessionId,
537
+ sinceRevision: args.since_revision,
538
+ });
539
+ },
540
+ }),
541
+
513
542
  // ── esr_node ─────────────────────────────────────
514
543
  registerTool({
515
544
  name: "esr_node",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engram",
3
3
  "description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -15,6 +15,7 @@
15
15
  "main": "lib/index.js",
16
16
  "exports": {
17
17
  ".": "./lib/index.js",
18
+ "./compaction": "./lib/compaction.js",
18
19
  "./client": "./lib/client.js",
19
20
  "./package.json": "./package.json",
20
21
  "./cordis.patch.yml": "./cordis.patch.yml"
@@ -32,10 +33,10 @@
32
33
  "scripts": {
33
34
  "setup": "node scripts/setup-links.mjs",
34
35
  "prepublishOnly": "node scripts/prepublish-check.mjs",
35
- "build:client": "node client/build.mjs",
36
+ "build:client": "node client/build.mjs && node client/build-model.mjs",
36
37
  "test": "node --test \"test/**/*.test.mjs\"",
37
38
  "eval": "node eval/recall-bench.mjs",
38
- "typecheck": "echo ok"
39
+ "typecheck": "node client/typecheck.mjs"
39
40
  },
40
41
  "publishConfig": {
41
42
  "access": "public"
@@ -60,7 +61,10 @@
60
61
  "@deepseek-ai/cordis": "^4.0.1",
61
62
  "@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.7",
62
63
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
63
- "@deepseek-ai/schemastery": "^3.18.1"
64
+ "@deepseek-ai/schemastery": "^3.18.1",
65
+ "@deepseek-ai/dsh-compaction": "^0.1.0-rc.7",
66
+ "@deepseek-ai/dsh-compaction-basic": "^0.1.0-rc.7",
67
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.7"
64
68
  },
65
69
  "peerDependenciesMeta": {
66
70
  "@deepseek-ai/cordis": {
@@ -72,6 +76,15 @@
72
76
  "@deepseek-ai/dsh-settings": {
73
77
  "optional": true
74
78
  },
79
+ "@deepseek-ai/dsh-compaction": {
80
+ "optional": true
81
+ },
82
+ "@deepseek-ai/dsh-compaction-basic": {
83
+ "optional": true
84
+ },
85
+ "@deepseek-ai/dsh-llm": {
86
+ "optional": true
87
+ },
75
88
  "@deepseek-ai/schemastery": {
76
89
  "optional": true
77
90
  }