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 +24 -0
- package/README.zh.md +14 -0
- package/lib/api.js +8 -0
- package/lib/index-block.js +144 -22
- package/lib/index.js +28 -2
- package/lib/store.js +16 -0
- package/lib/tools.js +69 -8
- package/lib/trigger.js +379 -0
- package/package.json +1 -1
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),
|
package/lib/index-block.js
CHANGED
|
@@ -130,16 +130,36 @@ function enforceCharCap(lines, maxChars) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* One-line
|
|
134
|
-
*
|
|
135
|
-
* its own under-proactivity
|
|
136
|
-
* Returns "" when there is nothing to correct (no data,
|
|
137
|
-
* or the balance is healthy) — the reminder disappears
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
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} --${
|
|
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 —
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|