dsh-engram 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -33,6 +33,30 @@ with one goal: **save tokens**.
33
33
  `force:true` (or disabling the toggle) skips the disk check, the three gates
34
34
  are still mandatory. The tool and the web forms share one gate
35
35
  (`store.evidenceGate`), so the two surfaces can never drift.
36
+ Tasks are never isolated graph nodes: `esr_task(entity=…)` one-call wiring
37
+ auto-creates the `ent_<slug>` node and hangs the task on it
38
+ (`task --relates_to--> entity`, idempotent), so a task created with a domain
39
+ anchor immediately shows a relation; and `esr_dep` writes the dependency edge
40
+ into both the task's `deps` (blocker logic) and the shared links table, making
41
+ every task dependency a first-class visible edge in the graph.
42
+ - **Decision-point triggers (P0-A/B + P2 + P3)** — firing where the todo→esr
43
+ routing decision actually happens and where tasks leak: a `tools/result`
44
+ watcher snapshots the native `todo_write` plan and, when the session has ≥ 2
45
+ pending todos that outnumber its active ESR tasks, the [ESR] block appends a
46
+ concrete `promote:` hint (`esr_task(name="…")`, once per session); recurring
47
+ failures (error memories with hits ≥ 2) earn a `root-cause:` task candidate;
48
+ a completed plan (pending → 0) earns a `close:` nudge for evidence-complete
49
+ tasks; long-unupdated active tasks get a `stale:` nudge; and the task list is
50
+ ordered READY-first with a `next: esr_close <id>` line, so the model can act
51
+ top-down. The same subscription feeds an in-memory mem-vs-esr counter that
52
+ restores `escalationHint` (the per-day `usage` table stopped being written
53
+ after assessment P3, silently orphaning the old nudge). Pure rules, zero
54
+ LLM, zero per-render I/O. Every hint line carries a stable `#suggest-*`
55
+ source tag (traceable in logs) and the same recorder measures hint → esr_*
56
+ conversion (shown once per session per hint, attributed when an esr tool
57
+ runs within a 10-minute window) — `GET /api/dsh-engram/triggerstats`
58
+ reports per-kind shown/converted/rate so you can see which nudge actually
59
+ moves behaviour.
36
60
  - **Memory GC (pi-esr constraints)** — a scheduled, mechanical, archive-only
37
61
  sweep: TTL-expired memories are archived, over-cap workspaces evict the
38
62
  lowest-value entries, stable tasks past their retention window leave the
package/README.zh.md CHANGED
@@ -23,6 +23,20 @@
23
23
  artifact 按工作区(= 会话 cwd)解析并在磁盘上实存校验,路径不存在则任务保持 ACTIVE、给出原因;
24
24
  `force:true`(或关掉该开关)可跳过磁盘校验——三种证据门照样必填。工具与网页表单共享同一个
25
25
  证据门(`store.evidenceGate`),口径绝不会漂移。
26
+ 任务不再是孤立节点:`esr_task(entity=…)` 一键挂点——自动建 `ent_<slug>` 节点并挂
27
+ `task --relates_to--> entity` 边(幂等),挂着领域锚点的任务立刻带关系;
28
+ `esr_dep` 双写——依赖边同时进 `tasks[].deps`(blocker 逻辑)与 links 表,图谱里每条
29
+ 任务依赖都是一等可见的边。
30
+ - **决策点触发(P0-A/B + P2 + P3)** — 在漏斗最窄处(todo 多、esr 少)与任务泄漏处(建了不关)
31
+ 给模型侧触发:监听 `tools/result` 里的原生 `todo_write`,当会话 pending todo ≥ 2 且多于 active ESR
32
+ 任务时,`[ESR]` 块追加带具体候选的 `promote:` 提示(`esr_task(name="…")`,每会话仅一次);
33
+ 复发失败(error hits≥2)给 `root-cause:` 任务候选;收工(todo 全部完成)给 `close:` 提示关证据齐的
34
+ 任务;长期无更新的 active 任务给 `stale:` 提示;活动任务按 READY 优先排序并给 `next: esr_close`。
35
+ 并用同一订阅维护进程内 mem-vs-esr 实时计数修复 `escalationHint` 的死数据源(评估 P3 移除了
36
+ per-call usage 写路径后原 nudge 已静默失效)。纯规则、零 LLM、零每轮 I/O;每条 nudge 带稳定 `#suggest-*` 来源标记,
37
+ 且 recorder 计量「提示曝光 → esr_* 调用」转化(每会话每 kind 记一次曝光,
38
+ esr 工具 10 分钟窗口内调用即归因),`GET /api/dsh-engram/triggerstats`
39
+ 返回各 kind 的 shown/converted/rate。
26
40
  - **记忆 GC(pi-esr 约束)** — 定时、机械、只归档的回收:TTL 过期记忆归档、超容量工作区淘汰低价值条目、
27
41
  stable 任务超保留窗离开 `[ESR]` 表面、悬空链接边清理。工作集(active 任务引用 / 任务记忆 / 已入索引命中)
28
42
  永不触碰;**不硬删任何东西**——归档条目保留 id、始终可重取。
package/lib/api.js CHANGED
@@ -442,6 +442,13 @@ export function makeEngramRoutes(service) {
442
442
  });
443
443
  };
444
444
 
445
+ const triggerstats = (_req, res) => {
446
+ const q = readQuery(_req);
447
+ const ws = q.get("workspace");
448
+ const stats = service.recorder?.conversionStats?.(ws && ws.length > 0 ? ws : void 0) ?? null;
449
+ writeJson(res, 200, { stats });
450
+ };
451
+
445
452
  const model = async (req, res) => {
446
453
  const domain = await withDomain(res);
447
454
  if (domain === void 0) return;
@@ -478,6 +485,7 @@ export function makeEngramRoutes(service) {
478
485
  make("GET", "/nodes", entities),
479
486
  make("GET", "/stats", stats),
480
487
  make("GET", "/toolstats", toolstats),
488
+ make("GET", "/triggerstats", triggerstats),
481
489
  make("GET", "/observations", observations),
482
490
  make("GET", "/model", model),
483
491
  make("GET", "/preview", preview),
@@ -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,146 @@ 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;
194
+ }
195
+
196
+ /**
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.
200
+ */
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
+ /** Order active tasks so the model can act top-down without re-scanning. */
211
+ export function sortActiveForNext(tasks) {
212
+ return [...tasks].sort((a, b) => {
213
+ const ra = taskNextRank(a);
214
+ const rb = taskNextRank(b);
215
+ if (ra !== rb) return ra - rb;
216
+ const ga = evidenceGaps(a).length;
217
+ const gb = evidenceGaps(b).length;
218
+ if (ga !== gb) return ga - gb;
219
+ return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Recurring-failure candidates (P0-B): error memories that earned
225
+ * `hits >= minErrorHits` through repeat-failure revival — durable breakage,
226
+ * the strongest "turn this into a root-cause task" signal. Most-hit first.
227
+ */
228
+ export function selectRootCauseCandidates(store, workspace, config) {
229
+ const min = (config?.minErrorHits ?? 2) | 0;
230
+ if (min < 1) return [];
231
+ return store
232
+ .listMemories(workspace, 200)
233
+ .filter((m) => m.kind === "error" && (m.hits ?? 0) >= min)
234
+ .sort((a, b) => (b.hits ?? 0) - (a.hits ?? 0) || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
235
+ .map((m) => ({ id: m.id, text: String(m.text), hits: m.hits ?? 0 }));
236
+ }
237
+
238
+ /**
239
+ * Stale-task candidates (P3): active tasks that have not been touched for
240
+ * `staleTaskDays` (default 14) — close with evidence or drop the intent.
241
+ */
242
+ export function selectStaleCandidates(tasks, config) {
243
+ const days = (config?.staleTaskDays ?? 14) | 0;
244
+ if (days < 1) return [];
245
+ const cutoff = Date.now() - days * 86_400_000;
246
+ return tasks
247
+ .filter((t) => (t.updatedAt ?? t.createdAt ?? 0) < cutoff)
248
+ .map((t) => ({ id: t.id, name: t.name, updatedAt: t.updatedAt ?? t.createdAt, createdAt: t.createdAt }));
159
249
  }
160
250
 
161
251
  /**
162
252
  * Render the [ESR] block — active tasks with closure gaps. Only appears when
163
253
  * at least one task exists. "STABLE" tasks are folded to a count line to save
164
254
  * tokens.
255
+ *
256
+ * @param opts {{
257
+ * recorder?: import("./trigger.js").TriggerRecorder, // decision-point recorder (P0-A–P3)
258
+ * sessionId?: string, // live session for the hints
259
+ * }}
165
260
  */
166
- export function renderEsr(store, workspace, config) {
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;
167
265
  const active = store.listTasks(workspace, { includeStable: false });
168
266
  const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
169
- const hint = escalationHint(store, workspace);
267
+ 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
+
170
286
  if (active.length === 0 && stable.length === 0) {
171
287
  // Keep the mechanism visible even when idle: one compact line teaches the
172
288
  // model that multi-step work earns an esr_task closed with real evidence.
173
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)";
174
- return hint === "" ? base : `${base}\n${hint}`;
290
+ const rest = [promote, rootCause, hint].filter((l) => l !== "");
291
+ return rest.length === 0 ? base : `${base}\n${rest.join("\n")}`;
175
292
  }
176
293
 
177
294
  const lines = [];
178
295
  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)}`);
296
+ const firstReady = ordered.find((t) => evidenceGaps(t).length === 0);
297
+ if (firstReady !== void 0) {
298
+ lines.push(`next: esr_close ${firstReady.id.slice(0, 6)} — ${escapeLt(truncate(firstReady.name, 40))} (all evidence present)`);
299
+ }
300
+ 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
301
  if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
181
302
  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);
182
307
  if (hint !== "") lines.push(hint);
183
308
  return lines.join("\n");
184
309
  }
185
310
 
186
311
  function taskGapSuffix(t) {
187
312
  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");
313
+ const gaps = evidenceGaps(t);
192
314
  return gaps.length === 0 ? " · READY to close" : ` · gap: ${gaps.join(", ")}`;
193
315
  }
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@ import { openEngramDomain } from "./store.js";
21
21
  import { registerTools } from "./tools.js";
22
22
  import { makeCaptureHandler } from "./capture.js";
23
23
  import { renderIndex, renderEsr } from "./index-block.js";
24
+ import { makeTriggerRecorder } from "./trigger.js";
24
25
  import { installEngramSettings } from "./settings.js";
25
26
  import { makeEngramRoutes } from "./api.js";
26
27
 
@@ -66,6 +67,16 @@ const DEFAULTS = {
66
67
  // prompt section order (before the persona/tools bands per DSH convention)
67
68
  engramIndexOrder: 40,
68
69
  esrOrder: 41,
70
+ // decision-point triggers (P0-A): promote a session plan to an ESR task
71
+ // when the pending-todo count is at least minTodosForPromote and exceeds
72
+ // active ESR tasks × todoPromoteRatio (default: pending > active).
73
+ minTodosForPromote: 2,
74
+ todoPromoteRatio: 1,
75
+ // P0-B: an error memory becomes a root-cause task candidate at this many
76
+ // repeat-failure hits.
77
+ minErrorHits: 2,
78
+ // P3: an active task with no update for this many days is surfaced as stale.
79
+ staleTaskDays: 14,
69
80
  };
70
81
 
71
82
  function resolveConfig(config) {
@@ -127,10 +138,15 @@ export function apply(ctx, config) {
127
138
  return domainPromise;
128
139
  };
129
140
 
141
+ // Decision-point triggers (P0-A + P1): live mem-vs-esr balance + the
142
+ // todo_write promote hook. Process-local; fed by a tools/result listener.
143
+ const recorder = makeTriggerRecorder(resolved);
144
+
130
145
  const service = {
131
146
  config: resolved,
132
147
  getDomain,
133
148
  openedDomain: () => opened,
149
+ recorder,
134
150
  ensureDomain: () => {
135
151
  getDomain().catch(() => {});
136
152
  },
@@ -153,7 +169,7 @@ export function apply(ctx, config) {
153
169
  renderEsrBlock(workspace) {
154
170
  if (opened === void 0) return "";
155
171
  try {
156
- return renderEsr(opened, workspace, resolved);
172
+ return renderEsr(opened, workspace, resolved, { recorder });
157
173
  } catch {
158
174
  return "";
159
175
  }
@@ -266,7 +282,10 @@ export function apply(ctx, config) {
266
282
  if (cwd === void 0 || cwd.length === 0) return "";
267
283
  if (opened === void 0) return "";
268
284
  try {
269
- return renderEsr(opened, resolvePath(cwd), resolved);
285
+ return renderEsr(opened, resolvePath(cwd), resolved, {
286
+ recorder,
287
+ sessionId: session.id,
288
+ });
270
289
  } catch {
271
290
  return "";
272
291
  }
@@ -275,6 +294,13 @@ export function apply(ctx, config) {
275
294
  disposers.push(() => esrDispose());
276
295
  }
277
296
 
297
+ // decision-point triggers: always on (independent of autoCapture) —
298
+ // feeds the live mem-vs-esr balance and the todo_write promote hint.
299
+ const disposeTrigger = ctx.on("tools/result", (exec, result) => {
300
+ recorder.handle(exec, result);
301
+ });
302
+ disposers.push(disposeTrigger);
303
+
278
304
  // zero-LLM auto-capture
279
305
  if (resolved.autoCapture) {
280
306
  // The capture handler needs a storeMemory face, but the domain opens
package/lib/store.js CHANGED
@@ -776,6 +776,22 @@ export async function openEngramDomain(facility) {
776
776
  addLink(link) {
777
777
  return links.put(link.id, link);
778
778
  },
779
+ /** Idempotent variant: skip when the same (source, relation, target) edge
780
+ * already exists in the workspace — used by auto-wiring paths so repeated
781
+ * calls never duplicate a relation. */
782
+ async addLinkOnce(link) {
783
+ const dup = [...links.entries()]
784
+ .map(([, l]) => l)
785
+ .some(
786
+ (l) =>
787
+ l.workspace === link.workspace &&
788
+ l.source === link.source &&
789
+ l.target === link.target &&
790
+ (l.relation ?? "") === (link.relation ?? ""),
791
+ );
792
+ if (!dup) await links.put(link.id, link);
793
+ return !dup;
794
+ },
779
795
  linksFor(workspace, entity) {
780
796
  return [...links.entries()]
781
797
  .map(([, l]) => l)
package/lib/tools.js CHANGED
@@ -250,19 +250,20 @@ export function registerTools(ctx, service) {
250
250
  // ── esr_task ──────────────────────────────────────────────────────
251
251
  registerTool({
252
252
  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.",
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. Pass entity= to auto-create the node and link the task onto it (one call instead of esr_node+esr_link).",
254
254
  parameters: {
255
255
  type: "object",
256
256
  properties: {
257
257
  name: { type: "string", description: "Task name (required on create)" },
258
258
  description: { type: "string", description: "What must be produced / satisfied" },
259
259
  id: { type: "string", description: "Existing task id to update" },
260
+ entity: { type: "string", description: "Optional domain object to hang this task on — auto-creates ent_<slug> node and links task --relates_to--> it" },
260
261
  },
261
262
  required: ["name"],
262
263
  },
263
264
  output: TEXT_OUTPUT,
264
265
  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");
266
+ 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
267
  const caller = callerOf(exec);
267
268
  const domain = await requireDomain(service);
268
269
  const existing = args.id === void 0 ? void 0 : domain.getTask(caller.cwd, args.id);
@@ -290,9 +291,14 @@ export function registerTools(ctx, service) {
290
291
  };
291
292
  await domain.putTask(task);
292
293
  await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
294
+ const wiring = args.entity !== void 0
295
+ ? await wireTaskEntity(domain, caller.cwd, task.id, args.entity, caller.sessionId, now)
296
+ : "";
293
297
  const hint = esrModelingHint(domain, caller.cwd);
294
- return `task ${task.id} [${task.state}] — ${task.name}${hint ? `
295
- ${hint}` : ""}`;
298
+ const parts = [`task ${task.id} [${task.state}] — ${task.name}`];
299
+ if (wiring !== "") parts.push(wiring);
300
+ if (hint !== "") parts.push(hint);
301
+ return parts.join("\n");
296
302
  },
297
303
  }),
298
304
 
@@ -410,9 +416,26 @@ ${hint}` : ""}`;
410
416
  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
417
  const caller = callerOf(exec);
412
418
  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");
419
+ const taskId = resolveTaskId(domain, caller.cwd, args.task_id);
420
+ const depId = resolveTaskId(domain, caller.cwd, args.dep_id);
421
+ const kind = args.kind ?? "blocks";
422
+ const t = await domain.addDep(caller.cwd, taskId, depId, kind);
423
+ // Graph-hygiene (A): the same edge goes into the links table so the
424
+ // dependency is a first-class, visible relation everywhere (graph,
425
+ // /links, entity neighbourhood) — not just a blocker counter.
426
+ const now = Date.now();
427
+ await domain.addLinkOnce({
428
+ id: uuid(),
429
+ workspace: caller.cwd,
430
+ source: taskId,
431
+ relation: kind,
432
+ target: depId,
433
+ confidence: 1,
434
+ sessionId: caller.sessionId,
435
+ createdAt: now,
436
+ });
414
437
  await domain.markModelDirty?.(caller.cwd).catch?.(() => null);
415
- return `dep added: ${t.id} --${args.kind ?? "blocks"}--> ${args.dep_id} (total ${(t.deps ?? []).length} edges)`;
438
+ return `dep added: ${t.id} --${kind}--> ${depId} (total ${(t.deps ?? []).length} edges · now graphed)`;
416
439
  },
417
440
  }),
418
441
 
@@ -604,15 +627,53 @@ export function esrModelingHint(domain, workspace) {
604
627
  const entities = domain.listEntities(workspace);
605
628
  const links = domain.allLinks(workspace);
606
629
  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).";
630
+ 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
631
  }
609
632
  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.`;
633
+ 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
634
  } catch {
612
635
  return "";
613
636
  }
614
637
  }
615
638
 
639
+ /**
640
+ * Auto-wire a task onto a domain object (graph-hygiene fix): ensure the entity
641
+ * node exists (id = ent_<slug>, same derivation as esr_node) and add the
642
+ * one-way `task --relates_to--> entity` edge, idempotently. One call instead
643
+ * of esr_node + esr_link so a task is never an isolated node. Pure/mechanical,
644
+ * zero LLM. Returns a short report line ("" when entity is empty).
645
+ */
646
+ export async function wireTaskEntity(domain, workspace, taskId, entityName, sessionId, now = Date.now()) {
647
+ const name = String(entityName).trim().slice(0, 120);
648
+ if (name.length === 0) return "";
649
+ const entId = `ent_${slugId(name)}`;
650
+ let created = false;
651
+ if (domain.getEntity(workspace, entId) === void 0) {
652
+ await domain.putEntity({
653
+ id: entId,
654
+ workspace,
655
+ name,
656
+ description: "",
657
+ kind: "concept",
658
+ sessionId,
659
+ createdAt: now,
660
+ updatedAt: now,
661
+ });
662
+ created = true;
663
+ }
664
+ const added = await domain.addLinkOnce({
665
+ id: uuid(),
666
+ workspace,
667
+ source: taskId,
668
+ relation: "relates_to",
669
+ target: entId,
670
+ confidence: 1,
671
+ sessionId,
672
+ createdAt: now,
673
+ });
674
+ return `wired: ${taskId} --relates_to--> ${entId}${created ? " (node created)" : ""}${added ? "" : " (edge already exists)"}`;
675
+ }
676
+
616
677
  function collectNeighborhood(domain, workspace, items) {
617
678
  const involved = new Set();
618
679
  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.2",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",