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/README.md +91 -5
- package/README.zh.md +46 -4
- package/lib/api.js +83 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +19 -5
- package/lib/index.js +16 -0
- package/lib/mental.js +170 -0
- package/lib/obs.js +71 -0
- package/lib/rerank.js +50 -0
- package/lib/settings.js +3 -0
- package/lib/store.js +314 -32
- package/lib/tools.js +287 -61
- package/lib/usage.js +224 -60
- package/lib/util.js +107 -0
- package/package.json +3 -7
package/lib/usage.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-engram:
|
|
2
|
+
* dsh-engram: agent-behaviour observability — computed ON DEMAND from the
|
|
3
|
+
* session log stream, nothing recorded per tool call.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
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
|
}
|