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.
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 = [
@@ -250,19 +252,20 @@ export function registerTools(ctx, service) {
250
252
  // ── esr_task ──────────────────────────────────────────────────────
251
253
  registerTool({
252
254
  name: "esr_task",
253
- description: "Create/update an engineering task (draft→active). A task is only 'done' with evidence — finish via esr_close (artifact + evaluation + memory_ref). Set id to update an existing task.",
255
+ description: "Create/update an engineering task (draft→active). A task is only 'done' with evidence — finish via esr_close (artifact + evaluation + memory_ref). Set id to update an existing task. Pass entity= to auto-create the node and link the task onto it (one call instead of esr_node+esr_link).",
254
256
  parameters: {
255
257
  type: "object",
256
258
  properties: {
257
259
  name: { type: "string", description: "Task name (required on create)" },
258
260
  description: { type: "string", description: "What must be produced / satisfied" },
259
261
  id: { type: "string", description: "Existing task id to update" },
262
+ entity: { type: "string", description: "Optional domain object to hang this task on — auto-creates ent_<slug> node and links task --relates_to--> it" },
260
263
  },
261
264
  required: ["name"],
262
265
  },
263
266
  output: TEXT_OUTPUT,
264
267
  execute: async (raw, exec) => {
265
- const args = parse(z.object({ name: z.string().min(1).max(200), description: z.string().optional(), id: z.string().optional() }), raw, "esr_task");
268
+ const args = parse(z.object({ name: z.string().min(1).max(200), description: z.string().optional(), id: z.string().optional(), entity: z.string().optional() }), raw, "esr_task");
266
269
  const caller = callerOf(exec);
267
270
  const domain = await requireDomain(service);
268
271
  const existing = args.id === void 0 ? void 0 : domain.getTask(caller.cwd, args.id);
@@ -290,9 +293,14 @@ export function registerTools(ctx, service) {
290
293
  };
291
294
  await domain.putTask(task);
292
295
  await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
296
+ const wiring = args.entity !== void 0
297
+ ? await wireTaskEntity(domain, caller.cwd, task.id, args.entity, caller.sessionId, now)
298
+ : "";
293
299
  const hint = esrModelingHint(domain, caller.cwd);
294
- return `task ${task.id} [${task.state}] — ${task.name}${hint ? `
295
- ${hint}` : ""}`;
300
+ const parts = [`task ${task.id} [${task.state}] — ${task.name}`];
301
+ if (wiring !== "") parts.push(wiring);
302
+ if (hint !== "") parts.push(hint);
303
+ return parts.join("\n");
296
304
  },
297
305
  }),
298
306
 
@@ -410,9 +418,26 @@ ${hint}` : ""}`;
410
418
  const args = parse(z.object({ task_id: z.string().min(1), dep_id: z.string().min(1), kind: z.enum(["blocks", "relates-to", "parent-of"]).optional() }), raw, "esr_dep");
411
419
  const caller = callerOf(exec);
412
420
  const domain = await requireDomain(service);
413
- const t = await domain.addDep(caller.cwd, resolveTaskId(domain, caller.cwd, args.task_id), resolveTaskId(domain, caller.cwd, args.dep_id), args.kind ?? "blocks");
421
+ const taskId = resolveTaskId(domain, caller.cwd, args.task_id);
422
+ const depId = resolveTaskId(domain, caller.cwd, args.dep_id);
423
+ const kind = args.kind ?? "blocks";
424
+ const t = await domain.addDep(caller.cwd, taskId, depId, kind);
425
+ // Graph-hygiene (A): the same edge goes into the links table so the
426
+ // dependency is a first-class, visible relation everywhere (graph,
427
+ // /links, entity neighbourhood) — not just a blocker counter.
428
+ const now = Date.now();
429
+ await domain.addLinkOnce({
430
+ id: uuid(),
431
+ workspace: caller.cwd,
432
+ source: taskId,
433
+ relation: kind,
434
+ target: depId,
435
+ confidence: 1,
436
+ sessionId: caller.sessionId,
437
+ createdAt: now,
438
+ });
414
439
  await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
415
- return `dep added: ${t.id} --${args.kind ?? "blocks"}--> ${args.dep_id} (total ${(t.deps ?? []).length} edges)`;
440
+ return `dep added: ${t.id} --${kind}--> ${depId} (total ${(t.deps ?? []).length} edges · now graphed)`;
416
441
  },
417
442
  }),
418
443
 
@@ -487,6 +512,33 @@ ${hint}` : ""}`;
487
512
  },
488
513
  }),
489
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
+
490
542
  // ── esr_node ─────────────────────────────────────
491
543
  registerTool({
492
544
  name: "esr_node",
@@ -604,15 +656,53 @@ export function esrModelingHint(domain, workspace) {
604
656
  const entities = domain.listEntities(workspace);
605
657
  const links = domain.allLinks(workspace);
606
658
  if (entities.length === 0) {
607
- return "modeling: no entity graph in this workspace yet — create esr_node entries for recurring domain objects, then esr_link to connect them (graph: GUI Settings → Engram).";
659
+ return "modeling: no entity graph in this workspace yet — esr_task(entity=…) auto-creates the node and hangs the task on it; or use esr_node for recurring domain objects, then esr_link to connect (graph: GUI Settings → Engram).";
608
660
  }
609
661
  const names = entities.slice(0, 3).map((e) => e.name).join(", ");
610
- return `modeling: entities ${names}${entities.length > 3 ? ` +${entities.length - 3}` : ""} / links ${links.length} — esr_link can wire new tasks & nodes into this graph.`;
662
+ return `modeling: entities ${names}${entities.length > 3 ? ` +${entities.length - 3}` : ""} / links ${links.length} — esr_task(entity=…) hangs new work onto a node; esr_link can wire tasks & nodes further into this graph.`;
611
663
  } catch {
612
664
  return "";
613
665
  }
614
666
  }
615
667
 
668
+ /**
669
+ * Auto-wire a task onto a domain object (graph-hygiene fix): ensure the entity
670
+ * node exists (id = ent_<slug>, same derivation as esr_node) and add the
671
+ * one-way `task --relates_to--> entity` edge, idempotently. One call instead
672
+ * of esr_node + esr_link so a task is never an isolated node. Pure/mechanical,
673
+ * zero LLM. Returns a short report line ("" when entity is empty).
674
+ */
675
+ export async function wireTaskEntity(domain, workspace, taskId, entityName, sessionId, now = Date.now()) {
676
+ const name = String(entityName).trim().slice(0, 120);
677
+ if (name.length === 0) return "";
678
+ const entId = `ent_${slugId(name)}`;
679
+ let created = false;
680
+ if (domain.getEntity(workspace, entId) === void 0) {
681
+ await domain.putEntity({
682
+ id: entId,
683
+ workspace,
684
+ name,
685
+ description: "",
686
+ kind: "concept",
687
+ sessionId,
688
+ createdAt: now,
689
+ updatedAt: now,
690
+ });
691
+ created = true;
692
+ }
693
+ const added = await domain.addLinkOnce({
694
+ id: uuid(),
695
+ workspace,
696
+ source: taskId,
697
+ relation: "relates_to",
698
+ target: entId,
699
+ confidence: 1,
700
+ sessionId,
701
+ createdAt: now,
702
+ });
703
+ return `wired: ${taskId} --relates_to--> ${entId}${created ? " (node created)" : ""}${added ? "" : " (edge already exists)"}`;
704
+ }
705
+
616
706
  function collectNeighborhood(domain, workspace, items) {
617
707
  const involved = new Set();
618
708
  for (const m of items ?? []) {
package/lib/trigger.js ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * dsh-engram: decision-point ESR triggers (P0-A + P1).
3
+ *
4
+ * The old mem-vs-esr escalation nudge (`escalationHint`) read the per-
5
+ * (workspace, day) `usage` table, but assessment P3 removed the per-call
6
+ * write path (observability moved to on-demand session-log scans), leaving
7
+ * the nudge with a dead data source. This module restores that balance
8
+ * signal with a cheap in-memory counter fed by the SAME tools/result
9
+ * subscription — zero I/O per prompt render, zero LLM — and adds the P0-A
10
+ * decision-point trigger:
11
+ *
12
+ * Watch the native `todo_write` calls, snapshot the session's pending
13
+ * todos, and let the [ESR] block emit a concrete "promote to esr_task"
14
+ * hint — at most once per session, only when the funnel is actually
15
+ * narrow (pending todos > active ESR tasks). This mirrors the GUI's
16
+ * "沉淀到 ESR" button on the model side, firing exactly where the
17
+ * todo→esr routing decision happens.
18
+ *
19
+ * All state is process-local (resets on host reload — acceptable for a
20
+ * nudge, same trade-off as a short-lived cache). Fully contained: handle()
21
+ * never throws, so a trigger bug can never disturb the tool pipeline.
22
+ */
23
+
24
+ import { workspaceKey as wk, truncate } from "./util.js";
25
+ import { dayKey } from "./usage.js";
26
+
27
+ /** Tools that count as memory-ops in the mem-vs-esr balance. */
28
+ export const MEM_TOOLS = [
29
+ "engram_store",
30
+ "engram_recall",
31
+ "engram_detail",
32
+ "loom_store",
33
+ "loom_recall",
34
+ "loom_detail",
35
+ ];
36
+ /** Tools that count as ESR-ops in the mem-vs-esr balance. */
37
+ export const ESR_TOOLS = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
38
+
39
+ /** Keep at most this many workspace snapshots (todo + usage) in memory. */
40
+ const WORKSPACE_CAP = 200;
41
+ /** Balance rolling window (days). */
42
+ const BALANCE_DAYS = 14;
43
+ const DAY_MS = 86_400_000;
44
+ /** Healthy once esr exceeds ~a third of memory-ops volume (matches P0-era). */
45
+ const HEALTHY_RATIO = 0.34;
46
+ /** Under this many memory ops a nudge is lecturing noise. */
47
+ const SAMPLE_FLOOR = 3;
48
+
49
+ /** Statuses a todo item wears when it is still unfinished. */
50
+ const DONE_STATUSES = new Set(["completed", "done", "complete", "closed"]);
51
+
52
+ /**
53
+ * Stable per-hint source tags (P4). Appended to every nudge line so the
54
+ * model can reference them and any log watcher can attribute which hint was
55
+ * shown; `emitHint`/`conversionStats` measure hint → esr_* action conversion.
56
+ */
57
+ export const HINT_TAGS = {
58
+ promote: "#suggest-promote",
59
+ rootcause: "#suggest-rootcause",
60
+ close: "#suggest-close",
61
+ stale: "#suggest-stale",
62
+ escalate: "#suggest-escalate",
63
+ };
64
+
65
+ /** A hint counts as "converted" when an esr_* tool runs within this window. */
66
+ const CONVERSION_WINDOW_MS = 10 * 60 * 1000;
67
+
68
+ /**
69
+ * Deterministically extract the list of unfinished todo names from a
70
+ * `todo_write` arguments object. Never throws; returns [] for anything else.
71
+ */
72
+ export function pendingTodos(args) {
73
+ if (args === null || typeof args !== "object") return [];
74
+ const raw = Array.isArray(args.todos) ? args.todos : [];
75
+ const names = [];
76
+ for (const item of raw) {
77
+ if (item === null || item === void 0) continue;
78
+ if (typeof item === "string") {
79
+ const c = item.trim();
80
+ if (c.length > 0) names.push(c);
81
+ continue;
82
+ }
83
+ if (typeof item !== "object") continue;
84
+ const status = String(item.status ?? item.state ?? "").toLowerCase();
85
+ if (DONE_STATUSES.has(status)) continue;
86
+ const content = String(item.content ?? item.name ?? item.title ?? "").trim();
87
+ if (content.length > 0) names.push(content);
88
+ }
89
+ return names;
90
+ }
91
+
92
+ /**
93
+ * The decision-point recorder. Feed every tools/result event to `handle()`;
94
+ * read back the live balance via `recentBalance(workspace)` and the one-shot
95
+ * promote prompt via `promoteHint(sessionId, workspace, activeTasks)`.
96
+ */
97
+ export function makeTriggerRecorder(config = {}) {
98
+ const minTodosForPromote = config.minTodosForPromote ?? 2;
99
+ const todoPromoteRatio = config.todoPromoteRatio ?? 1;
100
+ /** workspace -> Map<day, {mem, esr}> (live balance, replaces dead usage table). */
101
+ const usage = new Map();
102
+ /** workspace -> { sessionId, count, names, updatedAt } (pending-todo plan). */
103
+ const todos = new Map();
104
+ /** `ws::sessionId` already got its promote hint this session. */
105
+ const promoted = new Set();
106
+ /** `ws::sessionId::errId` — root-cause hint already shown for that mem. */
107
+ const errorSeen = new Set();
108
+ /** `ws::sessionId` — its todos went pending → 0 (work finished). */
109
+ const workDone = new Set();
110
+ /** `ws::sessionId` — closure hint already shown. */
111
+ const closureSeen = new Set();
112
+ /** `ws::sessionId::taskId` — stale hint already shown for that task. */
113
+ const staleSeen = new Set();
114
+ /** `ws::sessionId::kind` — hint already emitted for that session (P4). */
115
+ const hintSeen = new Set();
116
+ /** ws -> Map<kind, shown|converted> — conversion measurement (P4). */
117
+ const shownCounts = new Map();
118
+ const convertedCounts = new Map();
119
+ /** Unconverted hints within the window: {kind, ws, session, shownAt}. */
120
+ const pending = [];
121
+
122
+ function bump(workspace, key) {
123
+ const day = dayKey();
124
+ let buckets = usage.get(workspace);
125
+ if (buckets === void 0) {
126
+ buckets = new Map();
127
+ usage.set(workspace, buckets);
128
+ }
129
+ const cur = buckets.get(day) ?? { mem: 0, esr: 0 };
130
+ cur[key] += 1;
131
+ buckets.set(day, cur);
132
+ if (buckets.size > BALANCE_DAYS + 2) {
133
+ const oldest = dayKey(Date.now() - BALANCE_DAYS * DAY_MS);
134
+ for (const d of [...buckets.keys()]) if (d < oldest) buckets.delete(d);
135
+ }
136
+ if (usage.size > WORKSPACE_CAP) {
137
+ const first = usage.keys().next().value;
138
+ usage.delete(first);
139
+ }
140
+ }
141
+
142
+ /** Count one tool call toward the mem/esr balance (no-op for other tools). */
143
+ function record(name, workspace) {
144
+ if (!workspace) return;
145
+ if (MEM_TOOLS.includes(name)) bump(workspace, "mem");
146
+ else if (ESR_TOOLS.includes(name)) bump(workspace, "esr");
147
+ }
148
+
149
+ /** tools/result listener. Fire-and-forget; never throws. */
150
+ function handle(exec, result) {
151
+ // eslint-disable-next-line no-unused-vars
152
+ void result;
153
+ try {
154
+ const name = String(exec?.name ?? "").toLowerCase();
155
+ const agent = exec?.agent;
156
+ const sessionId = agent?.session?.id;
157
+ const cwd = agent?.session?.header?.cwd;
158
+ if (!sessionId || typeof cwd !== "string" || cwd.length === 0) return;
159
+ const workspace = wk(cwd);
160
+ record(name, workspace);
161
+ // P4 conversion: an esr_* call inside the window converts this
162
+ // workspace's most recent pending hint.
163
+ if (ESR_TOOLS.includes(name)) attributeConversion(workspace, Date.now());
164
+ if (name !== "todo_write") return;
165
+ // Snapshot this session's pending plan. When a plan that had pending
166
+ // todos transitioned to a fully-completed list, that is a natural
167
+ // "work finished" event → enable the closure hint (P3).
168
+ const names = pendingTodos(exec?.arguments);
169
+ const prev = todos.get(workspace);
170
+ const prevCount = prev !== void 0 && prev.sessionId === sessionId ? prev.count : -1;
171
+ todos.set(workspace, { sessionId, count: names.length, names, updatedAt: Date.now() });
172
+ if (prevCount > 0 && names.length === 0) {
173
+ workDone.add(`${workspace}::${sessionId}`);
174
+ }
175
+ if (todos.size > WORKSPACE_CAP) {
176
+ const first = todos.keys().next().value;
177
+ todos.delete(first);
178
+ }
179
+ } catch {
180
+ // fully contained — a trigger bug must never disturb the tool pipeline
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Live mem-vs-esr balance for a workspace (last BALANCE_DAYS).
186
+ * Returns { memCalls, esrCalls, days }; all zeros when no data yet.
187
+ */
188
+ function recentBalance(workspace) {
189
+ const buckets = usage.get(workspace);
190
+ if (buckets === void 0) return { memCalls: 0, esrCalls: 0, days: 0 };
191
+ const oldest = dayKey(Date.now() - BALANCE_DAYS * DAY_MS);
192
+ let memCalls = 0;
193
+ let esrCalls = 0;
194
+ let days = 0;
195
+ for (const [day, c] of buckets) {
196
+ if (day < oldest) continue;
197
+ days += 1;
198
+ memCalls += c.mem;
199
+ esrCalls += c.esr;
200
+ }
201
+ return { memCalls, esrCalls, days };
202
+ }
203
+
204
+ // ── P4 conversion measurement ────────────────────────────────────────────
205
+
206
+ /** Count a per-(workspace, kind) bucket. */
207
+ function bumpCount(map, workspace, kind, delta) {
208
+ let perWs = map.get(workspace);
209
+ if (perWs === void 0) {
210
+ perWs = new Map();
211
+ map.set(workspace, perWs);
212
+ }
213
+ perWs.set(kind, (perWs.get(kind) ?? 0) + delta);
214
+ }
215
+
216
+ /** Merge per-workspace buckets; when ws is given, read only that one. */
217
+ function countsFor(map, workspace) {
218
+ if (workspace !== void 0) return map.get(workspace) ?? new Map();
219
+ const out = new Map();
220
+ for (const perWs of map.values()) {
221
+ for (const [k, n] of perWs) out.set(k, (out.get(k) ?? 0) + n);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /** Drop hints past the conversion window (or when already attributed). */
227
+ function prunePending() {
228
+ const cutoff = Date.now() - CONVERSION_WINDOW_MS;
229
+ for (let i = pending.length - 1; i >= 0; i--) {
230
+ if (pending[i].shownAt < cutoff) pending.splice(i, 1);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Record that a hint line was actually injected for a session (P4). Each
236
+ * (workspace, session, kind) counts at most once, matching the once-per-
237
+ * session hint semantics — repeated renders of the same hint do not inflate
238
+ * the exposure count.
239
+ */
240
+ function emitHint(kind, workspace, sessionId) {
241
+ if (!kind || !HINT_TAGS[kind] || !workspace || !sessionId) return;
242
+ const key = `${workspace}::${sessionId}::${kind}`;
243
+ if (hintSeen.has(key)) return;
244
+ hintSeen.add(key);
245
+ bumpCount(shownCounts, workspace, kind, 1);
246
+ pending.push({ kind, ws: workspace, session: sessionId, shownAt: Date.now() });
247
+ prunePending();
248
+ }
249
+
250
+ /**
251
+ * Attribute the most recent pending hint of this workspace (inside the
252
+ * window) to an esr_* tool call. One call converts one hint — attribution
253
+ * stays honest, the rest expire on their own.
254
+ */
255
+ function attributeConversion(workspace, at) {
256
+ let best = -1;
257
+ for (let i = 0; i < pending.length; i++) {
258
+ const h = pending[i];
259
+ if (h.ws !== workspace) continue;
260
+ if (at - h.shownAt > CONVERSION_WINDOW_MS) continue;
261
+ if (best === -1 || h.shownAt > pending[best].shownAt) best = i;
262
+ }
263
+ if (best === -1) return;
264
+ const h = pending[best];
265
+ pending.splice(best, 1);
266
+ bumpCount(convertedCounts, workspace, h.kind, 1);
267
+ }
268
+
269
+ /**
270
+ * Hint → esr_* action conversion (P4). Returns a plain, serializable object:
271
+ * { windowMs, total: {shown, converted, rate}, byKind: { <kind>: {shown, converted, rate} } }
272
+ * When `workspace` is omitted, aggregates across all workspaces.
273
+ */
274
+ function conversionStats(workspace) {
275
+ prunePending();
276
+ const shown = countsFor(shownCounts, workspace);
277
+ const converted = countsFor(convertedCounts, workspace);
278
+ const byKind = {};
279
+ let shownTotal = 0;
280
+ let convertedTotal = 0;
281
+ for (const kind of Object.keys(HINT_TAGS)) {
282
+ const sh = shown.get(kind) ?? 0;
283
+ const cv = converted.get(kind) ?? 0;
284
+ shownTotal += sh;
285
+ convertedTotal += cv;
286
+ byKind[kind] = { shown: sh, converted: cv, rate: sh > 0 ? +(cv / sh).toFixed(4) : null };
287
+ }
288
+ return {
289
+ windowMs: CONVERSION_WINDOW_MS,
290
+ total: {
291
+ shown: shownTotal,
292
+ converted: convertedTotal,
293
+ rate: shownTotal > 0 ? +(convertedTotal / shownTotal).toFixed(4) : null,
294
+ },
295
+ byKind,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * One-shot "promote your plan to an ESR task" hint for the live session.
301
+ * Returns "" unless: this session wrote ≥ minTodosForPromote pending todos,
302
+ * the funnel is narrow (pending > active * todoPromoteRatio), and the hint
303
+ * has not already been emitted this session. Marks the hint emitted on
304
+ * success so it appears at most once per session (no nagging).
305
+ */
306
+ function promoteHint(sessionId, workspace, activeTasks) {
307
+ if (!sessionId || !workspace) return "";
308
+ const snap = todos.get(workspace);
309
+ if (snap === void 0 || snap.sessionId !== sessionId) return "";
310
+ if (snap.count < minTodosForPromote) return "";
311
+ if (snap.count <= activeTasks * todoPromoteRatio) return "";
312
+ const key = `${workspace}::${sessionId}`;
313
+ if (promoted.has(key)) return "";
314
+ promoted.add(key);
315
+ const sample = snap.names
316
+ .slice(0, 2)
317
+ .map((n) => truncate(String(n).replace(/\s*\n+\s*/g, " ").trim(), 36))
318
+ .join(" / ");
319
+ return `promote: ${snap.count} pending todo(s) vs ${activeTasks} ESR task(s) — multi-step work earns a cross-session task now: esr_task(name="${sample}") ${HINT_TAGS.promote}`;
320
+ }
321
+
322
+ /**
323
+ * One-shot "root-cause a recurring failure" hint (P0-B). Candidates come
324
+ * from the store (error memories that earned `hits >= minErrorHits` through
325
+ * repeat-failure revival) — the recorder only dedupes which error ids have
326
+ * been surfaced per session. Returns the first not-yet-shown candidate as a
327
+ * line, or "".
328
+ */
329
+ function errorHint(sessionId, workspace, candidates) {
330
+ if (!sessionId || !workspace) return "";
331
+ if (!Array.isArray(candidates) || candidates.length === 0) return "";
332
+ for (const c of candidates) {
333
+ const key = `${workspace}::${sessionId}::${c.id}`;
334
+ if (errorSeen.has(key)) continue;
335
+ errorSeen.add(key);
336
+ const text = truncate(String(c.text).replace(/\s*\n+\s*/g, " ").trim(), 48);
337
+ return `root-cause: ${text} ×${c.hits ?? 1} — esr_task it so the pattern dies ${HINT_TAGS.rootcause}`;
338
+ }
339
+ return "";
340
+ }
341
+
342
+ /**
343
+ * One-shot "work is finished — close the READY tasks" hint (P3). Fires only
344
+ * after this session's plan went pending → 0 (recorded from todo_write),
345
+ * only when at least one active task has all evidence, and at most once per
346
+ * session.
347
+ */
348
+ function closureHint(sessionId, workspace, readyTasks) {
349
+ if (!sessionId || !workspace) return "";
350
+ if (!Array.isArray(readyTasks) || readyTasks.length === 0) return "";
351
+ const key = `${workspace}::${sessionId}`;
352
+ if (!workDone.has(key)) return "";
353
+ if (closureSeen.has(key)) return "";
354
+ closureSeen.add(key);
355
+ const t = readyTasks[0];
356
+ return `close: task ${t.id.slice(0, 6)} · ${truncate(t.name, 40)} — evidence complete, esr_close now (artifact/evaluation/memory_ref) ${HINT_TAGS.close}`;
357
+ }
358
+
359
+ /**
360
+ * One-shot "this task has gone stale" hint (P3). Candidates are active tasks
361
+ * with no update for `staleTaskDays`. Each task id is surfaced at most once
362
+ * per session (one line per render).
363
+ */
364
+ function staleHint(sessionId, workspace, staleTasks) {
365
+ if (!sessionId || !workspace) return "";
366
+ if (!Array.isArray(staleTasks) || staleTasks.length === 0) return "";
367
+ for (const t of staleTasks) {
368
+ const key = `${workspace}::${sessionId}::${t.id}`;
369
+ if (staleSeen.has(key)) continue;
370
+ staleSeen.add(key);
371
+ const at = t.updatedAt ?? t.createdAt ?? Date.now();
372
+ const days = Math.max(1, Math.round((Date.now() - at) / DAY_MS));
373
+ return `stale: ${t.id.slice(0, 6)} · ${truncate(t.name, 40)} — no update ${days}d, esr_close with evidence or drop the intent ${HINT_TAGS.stale}`;
374
+ }
375
+ return "";
376
+ }
377
+
378
+ return { handle, record, recentBalance, promoteHint, errorHint, closureHint, staleHint, emitHint, conversionStats };
379
+ }
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.0",
4
+ "version": "0.3.3",
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"
@@ -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
  }