dsh-engram 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/usage.js CHANGED
@@ -1,16 +1,24 @@
1
1
  /**
2
- * dsh-engram: real-data agent-behaviour observability.
2
+ * dsh-engram: agent-behaviour observability — computed ON DEMAND from the
3
+ * session log stream, nothing recorded per tool call.
3
4
  *
4
- * Every engram_* / esr_* tool call made by the model is recorded into the
5
- * store's per-(workspace, day) `usage` rollup: counts per tool, failures,
6
- * and recall mechanics. The GUI (`/api/dsh-engram/stats`) turns that into the
7
- * "proactive ESR ratio" and "recall hit rate" numbers — real measurements
8
- * from real sessions, not a static benchmark.
5
+ * Assessment P3 (2026-08-22): the old design had TWO observability stacks —
6
+ * a write-path `UsageTracker` that bumped a per-(workspace,day) `usage` table
7
+ * on every engram/esr tool call, and a separate session-log scan that counted
8
+ * tool calls (to include the built-in `todo` tool). Both are now ONE module:
9
+ * both /stats and /toolstats answer from the canonical log stream
10
+ * `~/.dsh/sessions/<bucket>/<sid>/session.jsonl.zstd`, decompressed on demand
11
+ * with a short TTL cache. No tool call pays a write anymore (pure GUI-side
12
+ * observability), and the log stream is the single source of truth.
9
13
  *
10
- * Zero-dependency, fire-and-forget: recording must never disturb the tool
11
- * pipeline, so callers use `void record(...).catch(() => {})`.
14
+ * Zero-dependency: the host already carries the `zstd` CLI.
12
15
  */
13
16
 
17
+ import { execFileSync } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+
14
22
  import { workspaceKey as wk } from "./util.js";
15
23
 
16
24
  /** Local YYYY-MM-DD, so daily rollups land on calendar days for the user. */
@@ -20,6 +28,34 @@ export function dayKey(now = Date.now()) {
20
28
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
21
29
  }
22
30
 
31
+ /** A big session log far exceeds Node's 1MB default exec buffer. */
32
+ const MAX_BYTES = 300 * 1024 * 1024;
33
+ const CACHE_TTL_MS = 60_000;
34
+ const DAY_MS = 86_400_000;
35
+
36
+ const cache = new Map(); // `${root}|${days}|${kind}` -> { at, payload }
37
+
38
+ /** Default session-log root (the same tree the session index reads). */
39
+ export function sessionsRoot() {
40
+ return path.join(os.homedir(), ".dsh", "sessions");
41
+ }
42
+
43
+ function decodeZstd(file) {
44
+ try {
45
+ return execFileSync("zstd", ["-dc", file], { maxBuffer: MAX_BYTES }).toString("utf8");
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ function listDirOrEmpty(dir) {
52
+ try {
53
+ return fs.readdirSync(dir);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
23
59
  /**
24
60
  * Count recalled memory rows from a engram_recall output string. The tool
25
61
  * renders one `- <id> …` line per item (or a `no active memories…` / `no
@@ -34,63 +70,191 @@ export function recallStatsFromOutput(out) {
34
70
  return { withHits: hitsTotal > 0 ? 1 : 0, hitsTotal };
35
71
  }
36
72
 
37
- /** Session-local drill-through window: a engram_detail within N events of a hit recall counts as a follow-up. */
38
- const DETAIL_FOLLOW_WINDOW = 8;
73
+ /** Concatenate all text blocks of a `tool-result` content array. */
74
+ function blockText(content) {
75
+ if (!Array.isArray(content)) return "";
76
+ let out = "";
77
+ for (const c of content) {
78
+ if (typeof c?.text === "string") out += c.text;
79
+ }
80
+ return out;
81
+ }
39
82
 
40
83
  /**
41
- * One tracker per plugin instance. Holds the cross-call state used to infer
42
- * "recall → detail drill-through" from the raw tool stream.
84
+ * Count tool calls across recent session logs.
85
+ * @param opts - { root, days, now } — root is injectable for tests.
86
+ * @returns { days, files, events, tools, buckets, cachedAt }
87
+ * `tools` = tool name -> call count (most active first, across all buckets),
88
+ * `buckets` = bucket dir -> per-tool counts.
43
89
  */
44
- export function makeUsageTracker() {
45
- const recallFresh = new Map(); // sessionId -> event seq of last hit recall
46
-
47
- return {
48
- /**
49
- * Record one tool-call outcome. `opts`: { ok, recallOutput? }.
50
- * Resolves the caller from `exec` exactly like tools.js `callerOf`. Returns
51
- * a promise; callers should fire-and-forget it.
52
- */
53
- async record(service, exec, name, opts) {
54
- const agent = exec?.agent;
55
- if (agent === void 0) return;
56
- const rawCwd = agent.session?.header?.cwd;
57
- const sessionId = agent.session?.id;
58
- if (rawCwd === void 0 || rawCwd.length === 0 || sessionId === void 0) return;
59
- const workspace = wk(rawCwd);
60
- const seq = agent.session?.events?.length ?? 0;
61
-
62
- const counts = { [name]: 1 };
63
- const recall = {};
64
- if (name === "engram_recall") {
65
- if (opts.ok) {
66
- const { withHits, hitsTotal } = recallStatsFromOutput(opts.recallOutput);
67
- recall.queries = 1;
68
- recall.withHits = withHits;
69
- recall.hitsTotal = hitsTotal;
70
- if (withHits > 0) recallFresh.set(sessionId, seq);
71
- else recallFresh.delete(sessionId);
72
- } else {
73
- recallFresh.delete(sessionId);
74
- }
75
- } else if (name === "engram_detail" && opts.ok && recallFresh.has(sessionId)) {
76
- const at = recallFresh.get(sessionId);
77
- if (seq - at <= DETAIL_FOLLOW_WINDOW) {
78
- recall.detailFollows = 1;
79
- recallFresh.delete(sessionId);
90
+ export function collectToolCounts({ root = sessionsRoot(), days = 14, now = Date.now() } = {}) {
91
+ const cacheKey = `${root}|${days}|tools`;
92
+ const hit = cache.get(cacheKey);
93
+ if (hit && now - hit.at < CACHE_TTL_MS) return hit.payload;
94
+
95
+ const byTool = new Map();
96
+ const byBucket = new Map();
97
+ const cutoff = now - days * DAY_MS;
98
+ let files = 0;
99
+ let events = 0;
100
+
101
+ const buckets = listDirOrEmpty(root).filter((b) => b.startsWith("--"));
102
+ for (const bucket of buckets) {
103
+ const bdir = path.join(root, bucket);
104
+ for (const sid of listDirOrEmpty(bdir)) {
105
+ const file = path.join(bdir, sid, "session.jsonl.zstd");
106
+ let st;
107
+ try {
108
+ st = fs.statSync(file);
109
+ } catch {
110
+ continue;
111
+ }
112
+ if (st.mtimeMs < cutoff) continue;
113
+ files += 1;
114
+ const raw = decodeZstd(file);
115
+ const bucketCounts = byBucket.get(bucket) ?? new Map();
116
+ for (const line of raw.split("\n")) {
117
+ if (line.length === 0) continue;
118
+ let j;
119
+ try {
120
+ j = JSON.parse(line);
121
+ } catch {
122
+ continue;
80
123
  }
124
+ if (j?.type !== "tool/call") continue;
125
+ const name = j.data?.name;
126
+ if (typeof name !== "string" || name.length === 0) continue;
127
+ events += 1;
128
+ byTool.set(name, (byTool.get(name) ?? 0) + 1);
129
+ bucketCounts.set(name, (bucketCounts.get(name) ?? 0) + 1);
81
130
  }
131
+ if (bucketCounts.size > 0) byBucket.set(bucket, bucketCounts);
132
+ }
133
+ }
134
+
135
+ const tools = Object.fromEntries([...byTool.entries()].sort((a, b) => b[1] - a[1]));
136
+ const bucketsOut = {};
137
+ for (const [b, m] of byBucket) {
138
+ bucketsOut[b] = Object.fromEntries([...m.entries()].sort((a, b) => b[1] - a[1]));
139
+ }
82
140
 
141
+ const payload = { days, files, events, tools, buckets: bucketsOut, cachedAt: now };
142
+ cache.set(cacheKey, { at: now, payload });
143
+ return payload;
144
+ }
145
+
146
+ /** Resolve the workspace key of a session from its `session` header event. */
147
+ function workspaceFromSession(raw) {
148
+ for (const line of raw.split("\n")) {
149
+ if (line.length === 0) continue;
150
+ let j;
151
+ try {
152
+ j = JSON.parse(line);
153
+ } catch {
154
+ continue;
155
+ }
156
+ if (j?.type === "session" && typeof j.cwd === "string" && j.cwd.length > 0) {
157
+ return wk(j.cwd);
158
+ }
159
+ return null; // session header is the first event
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /** Session-local drill-through window: a engram_detail within N tool events of a hit recall counts as a follow-up. */
165
+ const DETAIL_FOLLOW_WINDOW = 8;
166
+
167
+ /**
168
+ * Rebuild the per-(workspace, day) usage rollup straight from the session log
169
+ * stream. Same shape as the old persisted rows, so the /stats aggregation is
170
+ * unchanged: each row is { workspace, day, counts, failures, recall } where
171
+ * `counts` = tool name -> calls, `failures` = errored results, `recall` =
172
+ * { queries, withHits, hitsTotal, detailFollows }.
173
+ *
174
+ * Workspace is resolved from each session's `type:session` header cwd; day
175
+ * from the file mtime (calendar-day granularity is all the GUI needs).
176
+ */
177
+ export function collectUsageStats({ root = sessionsRoot(), days = 14, now = Date.now() } = {}) {
178
+ const cacheKey = `${root}|${days}|usage`;
179
+ const hit = cache.get(cacheKey);
180
+ if (hit && now - hit.at < CACHE_TTL_MS) return hit.payload;
181
+
182
+ const rows = new Map(); // `${workspace}|${day}` -> row
183
+ const cutoff = now - days * DAY_MS;
184
+ let files = 0;
185
+ let events = 0;
186
+ let sessions = 0;
187
+
188
+ const buckets = listDirOrEmpty(root).filter((b) => b.startsWith("--"));
189
+ for (const bucket of buckets) {
190
+ for (const sid of listDirOrEmpty(path.join(root, bucket))) {
191
+ const file = path.join(root, bucket, sid, "session.jsonl.zstd");
192
+ let st;
83
193
  try {
84
- const domain = await service.getDomain();
85
- await domain.bumpUsage(workspace, dayKey(), {
86
- counts,
87
- failures: opts.ok ? 0 : 1,
88
- recall,
89
- });
90
- } catch (error) {
91
- // observability is best-effort; a store hiccup must not surface here
92
- service?.log?.warn?.(`engram usage record failed: ${String(error)}`);
194
+ st = fs.statSync(file);
195
+ } catch {
196
+ continue;
197
+ }
198
+ if (st.mtimeMs < cutoff) continue;
199
+ const raw = decodeZstd(file);
200
+ files += 1;
201
+ const workspace = workspaceFromSession(raw) ?? "?";
202
+ const day = dayKey(st.mtimeMs);
203
+ sessions += 1;
204
+
205
+ // Results come AFTER their call in the stream, so resolve them in a
206
+ // first pass and walk the calls in a second (index keeps stream order).
207
+ const results = new Map(); // callId -> { isError, text }
208
+ for (const line of raw.split("\n")) {
209
+ if (line.length === 0) continue;
210
+ let j;
211
+ try {
212
+ j = JSON.parse(line);
213
+ } catch {
214
+ continue;
215
+ }
216
+ if (j?.type !== "tool/result") continue;
217
+ const block = j.data?.message?.content?.find((c) => c?.type === "tool-result");
218
+ if (block?.toolCallId) {
219
+ results.set(block.toolCallId, { isError: block.isError === true, text: blockText(block.content) });
220
+ }
93
221
  }
94
- },
95
- };
96
- }
222
+ let windowLeft = -1; // stream index of last hit-recall
223
+ let idx = 0;
224
+ for (const line of raw.split("\n")) {
225
+ if (line.length === 0) continue;
226
+ let j;
227
+ try {
228
+ j = JSON.parse(line);
229
+ } catch {
230
+ continue;
231
+ }
232
+ if (j?.type !== "tool/call") continue;
233
+ const name = j.data?.name;
234
+ if (typeof name !== "string" || name.length === 0) continue;
235
+ idx += 1;
236
+ events += 1;
237
+ const result = j.data?.callId ? results.get(j.data.callId) : void 0;
238
+ const key = `${workspace}|${day}`;
239
+ const row = rows.get(key) ?? { workspace, day, counts: {}, failures: 0, recall: {} };
240
+ row.counts[name] = (row.counts[name] ?? 0) + 1;
241
+ if (result?.isError) row.failures += 1;
242
+ if (name === "engram_recall") {
243
+ row.recall.queries = (row.recall.queries ?? 0) + 1;
244
+ const rs = recallStatsFromOutput(result ? result.text : "");
245
+ row.recall.withHits = (row.recall.withHits ?? 0) + rs.withHits;
246
+ row.recall.hitsTotal = (row.recall.hitsTotal ?? 0) + rs.hitsTotal;
247
+ windowLeft = rs.withHits > 0 ? idx : -1;
248
+ } else if (name === "engram_detail" && windowLeft >= 0 && idx - windowLeft <= DETAIL_FOLLOW_WINDOW) {
249
+ row.recall.detailFollows = (row.recall.detailFollows ?? 0) + 1;
250
+ windowLeft = -1;
251
+ }
252
+ rows.set(key, row);
253
+ }
254
+ }
255
+ }
256
+
257
+ const payload = { days, files, events, sessions, rows: [...rows.values()], cachedAt: now };
258
+ cache.set(cacheKey, { at: now, payload });
259
+ return payload;
260
+ }
package/lib/util.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { basename, resolve } from "node:path";
7
+ import { evidenceBoost } from "./rerank.js";
7
8
 
8
9
  /** Random id for memories / tasks / links. */
9
10
  export function uuid() {
@@ -117,3 +118,109 @@ export function scoreRecord(record, tokens, fullQuery, phraseBoost) {
117
118
  export function byRecency(left, right) {
118
119
  return (right.updatedAt ?? 0) - (left.updatedAt ?? 0) || String(left.id).localeCompare(String(right.id));
119
120
  }
121
+
122
+ /**
123
+ * BM25-style ranking over an in-memory record corpus. Deterministic, zero
124
+ * dependencies — we deliberately do NOT build a SQLite/FTS index here: DSH
125
+ * already owns the cross-session full-text index (`ctx.sessionQuery`), which
126
+ * engram_recall's `search_sessions` piggybacks on; this ranks only the small
127
+ * in-domain memory pool, where an in-process IDF pass is cheap and keeps the
128
+ * store self-contained.
129
+ *
130
+ * Weights: BM25 (k1=1.5, b=0.75) over text+tags, plus the proven small boosts —
131
+ * exact tag match, fuzzy tag overlap, and a phrase boost for multi-word queries,
132
+ * and a gentle multiplicative recency factor (half-life 14 days, max +50%):
133
+ * freshness lifts near-ties and stale-but-still-matching results, but never
134
+ * overrides strong lexical relevance (Zep/decay-weighted recall direction).
135
+ * `now` is injectable so the ranking stays deterministic in tests.
136
+ * Returns up to `limit` records with score > 0, best first, recency as tiebreak.
137
+ */
138
+ export function bm25Rank(records, query, limit = 20, now = Date.now()) {
139
+ const DAY = 86400000;
140
+ const clamped = Math.max(0, typeof now === "number" ? now : Date.now());
141
+ const trimAgeDays = (r) => Math.max(0, (clamped - (r.updatedAt ?? r.createdAt ?? 0)) / DAY);
142
+ const trimmed = String(query ?? "").trim().toLowerCase();
143
+ const bounded = Math.max(1, Math.min(50, Math.trunc(limit) || 20));
144
+ if (trimmed.length === 0) return records.slice(0, bounded);
145
+ const tokens = tokenize(trimmed);
146
+ if (tokens.length === 0) return records.slice(0, bounded);
147
+
148
+ const N = Math.max(1, records.length);
149
+ const df = new Map();
150
+ const lens = new Array(records.length);
151
+ const blobs = records.map((r, i) => {
152
+ const text = String(r.text ?? "").toLowerCase();
153
+ const tags = (r.tags ?? []).map((t) => String(t).toLowerCase());
154
+ const blob = `${text} ${tags.join(" ")}`;
155
+ const terms = blob.split(/\s+/).filter(Boolean);
156
+ lens[i] = Math.max(1, terms.length);
157
+ for (const term of new Set(terms)) df.set(term, (df.get(term) ?? 0) + 1);
158
+ return { blob, tags, text };
159
+ });
160
+ const avgdl = lens.reduce((a, b) => a + b, 0) / N;
161
+ const K1 = 1.5;
162
+ const B = 0.75;
163
+
164
+ const scored = records
165
+ .map((r, i) => {
166
+ const { blob, tags, text } = blobs[i];
167
+ const len = lens[i];
168
+ let score = 0;
169
+ for (const token of tokens) {
170
+ const dfT = df.get(token) ?? 0;
171
+ const idf = Math.log(1 + (N - dfT + 0.5) / (dfT + 0.5));
172
+ let count = 0;
173
+ let from = 0;
174
+ for (;;) {
175
+ const at = blob.indexOf(token, from);
176
+ if (at === -1) break;
177
+ count += 1;
178
+ from = at + token.length;
179
+ }
180
+ const tf = count * (K1 + 1) / (count + K1 * (1 - B + (B * len) / avgdl));
181
+ score += idf * tf;
182
+ if (tags.includes(token)) score += 2;
183
+ else if (tags.some((tag) => tag.includes(token) || token.includes(tag))) score += 1;
184
+ }
185
+ if (/\s/.test(trimmed) && trimmed.length > 1 && text.includes(trimmed)) score += 3;
186
+ // Gentle time-awareness: 1 + 0.5·exp(-ageDays/14). New memories get up to
187
+ // +50%; after 14 days still +18%, after 60 days ~+1%. Never zeroes a hit.
188
+ score *= 1 + 0.5 * Math.exp(-trimAgeDays(r) / 14);
189
+ // Evidence strength (cumulative recall hits = proof count analogue, fed
190
+ // by the failure-revival path): conservative ±5% at alpha 0.1, saturating
191
+ // after 5 hits so no single memory can drown the lexical signal.
192
+ score *= evidenceBoost(r.hits ?? 0);
193
+ return { r, score };
194
+ })
195
+ .filter((e) => e.score > 0)
196
+ .sort((a, b) => b.score - a.score || byRecency(a.r, b.r));
197
+ return scored.slice(0, bounded).map((e) => e.r);
198
+ }
199
+
200
+ /**
201
+ * Session-diversified dedup (agentmemory 候选①): keep the input order
202
+ * (stable) but cap how many records the same sessionId may contribute.
203
+ *
204
+ * Rationale (AML coding board + agentmemory deep-dive): "few and precise"
205
+ * evidence beats letting one session flood the recall list — the platform
206
+ * answerer consumes a bounded evidence set, and single-session domination
207
+ * squeezes out cross-session relevance. Deterministic: pure function, no
208
+ * randomness, no reading beyond `records`.
209
+ *
210
+ * @param {Array<{sessionId?: string|null, id?: string}>} records ranked list, best first
211
+ * @param {number} [maxPerSession=3] max records kept per sessionId (>=1)
212
+ * @returns {Array} subset of records, order preserved
213
+ */
214
+ export function dedupeBySession(records, maxPerSession = 3) {
215
+ const cap = Math.max(1, Math.trunc(maxPerSession) || 3);
216
+ const seen = new Map();
217
+ const out = [];
218
+ for (const record of records) {
219
+ const key = record?.sessionId ?? record?.id ?? `__noid:${out.length}`;
220
+ const n = seen.get(key) ?? 0;
221
+ if (n >= cap) continue;
222
+ seen.set(key, n + 1);
223
+ out.push(record);
224
+ }
225
+ return out;
226
+ }
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.2.0",
4
+ "version": "0.3.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -38,8 +38,7 @@
38
38
  "typecheck": "echo ok"
39
39
  },
40
40
  "publishConfig": {
41
- "access": "public",
42
- "registry": "https://registry.npmjs.org"
41
+ "access": "public"
43
42
  },
44
43
  "dsh": {
45
44
  "bundle": {
@@ -85,8 +84,5 @@
85
84
  "long-term-memory",
86
85
  "symbolic-index",
87
86
  "pi-esr"
88
- ],
89
- "publishConfig": {
90
- "access": "public"
91
- }
87
+ ]
92
88
  }