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/README.md +113 -2
- package/README.zh.md +83 -3
- package/cordis.patch.yml +12 -0
- package/lib/api.js +32 -2
- package/lib/client.js +15 -1
- package/lib/compaction.js +85 -0
- package/lib/context-gc.js +626 -0
- package/lib/index-block.js +280 -28
- package/lib/index.js +100 -14
- package/lib/settings.js +4 -0
- package/lib/store.js +31 -0
- package/lib/tools.js +98 -8
- package/lib/trigger.js +379 -0
- package/package.json +15 -2
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
|
-
|
|
295
|
-
|
|
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
|
|
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} --${
|
|
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 —
|
|
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
|
|
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.
|
|
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
|
}
|