@modusensus/dsh-mneme 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-258%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-263%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -225,7 +225,7 @@ src/
225
225
  lib/
226
226
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
227
227
  └── *.js # src 的同步分发产物
228
- test/ # 258 个 node:test 测试(含审计与三轴线压测不变量)
228
+ test/ # 263 个 node:test 测试(含审计与三轴线压测不变量)
229
229
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
230
230
  ```
231
231
 
@@ -234,7 +234,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
234
234
  ```bash
235
235
  cd dsh-mneme
236
236
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
237
- npm test # 运行 258 个测试
237
+ npm test # 运行 263 个测试
238
238
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
239
239
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
240
240
  ```
package/lib/config.js CHANGED
@@ -13,6 +13,10 @@ export const Config = z.object({
13
13
  dreamProvider: z.string(),
14
14
  dreamModel: z.string(),
15
15
  dreamMaxTokens: z.natural().min(256).max(32768).default(4096),
16
+ // Rule version for dream adjudication: when this bumps, older dream_runs
17
+ // degrade to historical evidence (their receipts no longer drive live
18
+ // decisions). Default 0 = no versioning in use yet.
19
+ policyEpoch: z.natural().min(0).max(1000000).default(0),
16
20
 
17
21
  // --- API protection ------------------------------------------------------
18
22
  // Optional shared token for the plugin's HTTP API. Empty (default) keeps
@@ -244,7 +244,7 @@ function applyMerge(d, service, snapshot) {
244
244
  });
245
245
  return {
246
246
  applied: 1,
247
- committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance }
247
+ committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance, count_before: d.ids.length, count_after: 1 }
248
248
  };
249
249
  }
250
250
 
@@ -266,7 +266,7 @@ function applyConflict(d, service, snapshot) {
266
266
  });
267
267
  service.setArchived(d.loser, true);
268
268
  });
269
- return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser } };
269
+ return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
270
270
  }
271
271
 
272
272
  function applyUpdate(d, service, snapshot) {
@@ -288,5 +288,5 @@ function applyUpdate(d, service, snapshot) {
288
288
  importance: d.importance ?? cur.importance
289
289
  });
290
290
  });
291
- return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance } };
291
+ return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
292
292
  }
package/lib/dream.js CHANGED
@@ -68,7 +68,7 @@ export function parseReceipt(receipt) {
68
68
  // reconcile = decisions validated but one or more did not commit (CAS
69
69
  // conflict / transaction rollback) — the store diverges from the decision
70
70
  // list and the run must be reconciled, never reported as a fake ok.
71
- if (!runId || !/^(ok|failed|reconcile)$/.test(status)) return undefined;
71
+ if (!runId || !/^(ok|noop|degraded|reconcile|failed)$/.test(status)) return undefined;
72
72
  const count = Number(inputCount);
73
73
  const appliedN = Number(applied);
74
74
  if (!Number.isInteger(count) || !Number.isInteger(appliedN)) return undefined;
@@ -100,6 +100,78 @@ export function buildOutcome(decisions) {
100
100
  return { byId };
101
101
  }
102
102
 
103
+ /**
104
+ * Content-addressed digest of the memories a verdict was decided against
105
+ * (id + title + content + importance), sorted by id so identical inputs always
106
+ * hash the same. This is the per-record "判定依据" fingerprint: a receipt whose
107
+ * digest cannot be reproduced from the involved memories is a bare claim, and a
108
+ * digest match with a divergent outcome pinpoints drift to the exact record.
109
+ */
110
+ export function hashDecisionInput(memories) {
111
+ const canon = (memories ?? [])
112
+ .map((m) => [m.id, m.title, m.content, m.importance])
113
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
114
+ .map((p) => p.map((x) => String(x ?? "")).join(""))
115
+ .join("");
116
+ return createHash("sha256").update(canon).digest("hex");
117
+ }
118
+
119
+ /**
120
+ * Build the per-record receipts for a run's actually-committed mutable verdicts
121
+ * (merge/conflict/update) — one row per verdict in the receipt_chain. Inputs
122
+ * are drawn from the run snapshot (what the LLM actually arbitrated against),
123
+ * and the idempotency counters count_before → count_after come from the
124
+ * committed sub-step, so replaying the same decision must reproduce the same
125
+ * numbers. verdict starts "live"; a later policy_epoch upgrade will batch-mark
126
+ * older verdicts "historical" (a receipt_chain rewrite driven by the store's
127
+ * getLatestPolicyEpoch — out of scope for this pass), while "revoked" is
128
+ * reserved for verdicts later overturned by an explicit human decision.
129
+ */
130
+ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
131
+ const at = (id) => snapshot?.get?.(id);
132
+ const receipts = [];
133
+ for (const c of committed ?? []) {
134
+ const base = {
135
+ run_id: runId,
136
+ verdict: "live",
137
+ count_before: c.count_before,
138
+ count_after: c.count_after,
139
+ policy_epoch: policyEpoch,
140
+ created_at: new Date().toISOString()
141
+ };
142
+ if (c.action === "merge") {
143
+ receipts.push({
144
+ ...base,
145
+ receipt_id: randomUUID(),
146
+ record_id: c.keepSource,
147
+ kind: "merge",
148
+ input_digest: hashDecisionInput((c.ids ?? []).map(at).filter(Boolean)),
149
+ keep_source: c.keepSource,
150
+ sources: c.ids
151
+ });
152
+ } else if (c.action === "conflict") {
153
+ receipts.push({
154
+ ...base,
155
+ receipt_id: randomUUID(),
156
+ record_id: c.winner,
157
+ kind: "conflict",
158
+ input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
159
+ winner_id: c.winner,
160
+ loser_id: c.loser
161
+ });
162
+ } else if (c.action === "update") {
163
+ receipts.push({
164
+ ...base,
165
+ receipt_id: randomUUID(),
166
+ record_id: c.ids[0],
167
+ kind: "update",
168
+ input_digest: hashDecisionInput([at(c.ids[0])].filter(Boolean))
169
+ });
170
+ }
171
+ }
172
+ return receipts;
173
+ }
174
+
103
175
  /**
104
176
  * Consume an LLM stream and return the accumulated text. Direct text-delta
105
177
  * accumulation covers both the real protocol ({type:"text-delta", index, text})
@@ -286,9 +358,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
286
358
  // replayable too.
287
359
  const finish = (result) => {
288
360
  // status is derived from what actually committed: ok only when the full
289
- // decision list landed; reconcile when decisions were validated but some
290
- // did not commit (CAS conflict / rollback); failed on any LLM/validation
291
- // error. No fake "ok" for a partial commit.
361
+ // decision list landed (or a summary was refreshed); noop when nothing
362
+ // changed; degraded when real changes landed without a summary;
363
+ // reconcile when decisions were validated but some did not commit (CAS
364
+ // conflict / rollback); failed on any LLM/validation error. No fake "ok"
365
+ // for an empty or partial run.
292
366
  const status = result.status ?? (result.ok ? "ok" : "failed");
293
367
  const applied = result.applied ?? 0;
294
368
  const summaryStored = result.summary ?? false;
@@ -302,6 +376,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
302
376
  model: route?.model,
303
377
  snapshot_hash: snapshotHash,
304
378
  input_count: snapshot.size,
379
+ // 裁决规则版本号:config.policyEpoch(默认 0)。规则升级后该行保留
380
+ // 当时的 epoch,旧裁决据此降级为历史证据(store 层 getLatestPolicyEpoch
381
+ // 只负责读取当前生效版本,写入由这里完成)。
382
+ policy_epoch: config.policyEpoch ?? 0,
305
383
  // Full input snapshot (canonical fields) so the exact arbitration
306
384
  // input can be rebuilt offline from the audit row alone — the
307
385
  // digest + decisions + outcome triple makes silent errors locatable
@@ -418,6 +496,19 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
418
496
  // a target changed during the LLM call is skipped and reported as a
419
497
  // conflict instead of being overwritten (item ①).
420
498
  const { applied, conflicts, failures, committed } = applyDecisions(decisions, service, logger, snapshot);
499
+ // Per-record receipt chain: one row per actually-committed merge/conflict/
500
+ // update verdict, stamped with the decision-basis digest + idempotency
501
+ // counters (count_before → count_after). Written here, before the run audit
502
+ // row, so the verdict trail always precedes the run trail it belongs to.
503
+ // Bookkeeping: a write failure is logged and swallowed — it must never
504
+ // block the consolidation flow.
505
+ try {
506
+ for (const r of buildRecordReceipts({ runId, committed, snapshot, policyEpoch: config.policyEpoch ?? 0 })) {
507
+ service.saveReceipt(r);
508
+ }
509
+ } catch (error) {
510
+ logger?.warn?.(`dsh-mneme dream: failed to write per-record receipt: ${String(error)}`);
511
+ }
421
512
  // Attach the pre-update snapshot to the audit copy of each update decision
422
513
  // so the recorded row shows the before/after delta, not just the target.
423
514
  const auditDecisions = decisions.map((d) =>
@@ -432,6 +523,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
432
523
  const outcome = { ...buildOutcome(committed), conflicts, failures };
433
524
  // Decisions validated but not fully committed → reconcile (not ok).
434
525
  const partial = conflicts.length > 0 || failures.length > 0;
526
+ // No decision landed (all-keep, or every decision skipped as an idempotent
527
+ // replay) → nothing substantive changed. Distinct from a success: such a
528
+ // run must never be reported as ok, or the audit claims work that never
529
+ // happened and the scheduler refreshes the baseline on a false positive.
530
+ const noChange = applied === 0 && committed.every((c) => c.action === "keep");
435
531
 
436
532
  // Keep the vector index consistent with the post-dream store state.
437
533
  if (semantic?.embedder && semantic?.vectorIndex) {
@@ -476,9 +572,30 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
476
572
  } catch { /* best-effort */ }
477
573
  }
478
574
  }
575
+ // Honest status assignment (never a fake ok):
576
+ // reconcile — some decisions validated but did not commit (CAS/rollback).
577
+ // noop — nothing changed and no summary persisted: truly an empty
578
+ // run. ok:false keeps the scheduler from moving the baseline.
579
+ // ok — either real changes landed, or a fresh summary was stored
580
+ // (all-keep + summary is a substantive summary refresh).
581
+ // degraded — real consolidation landed but the summary came back empty/
582
+ // missing: the store was absorbed (ok for the baseline) but
583
+ // the run did not produce its full output (marked, not faked).
584
+ let status;
585
+ let okResult;
586
+ if (partial) {
587
+ status = "reconcile";
588
+ okResult = false;
589
+ } else if (noChange) {
590
+ status = summaryStored ? "ok" : "noop";
591
+ okResult = summaryStored;
592
+ } else {
593
+ status = summaryStored ? "ok" : "degraded";
594
+ okResult = true;
595
+ }
479
596
  return finish({
480
- ok: !partial,
481
- status: partial ? "reconcile" : "ok",
597
+ ok: okResult,
598
+ status,
482
599
  applied,
483
600
  decisions: auditDecisions,
484
601
  outcome,
package/lib/index.js CHANGED
@@ -44,6 +44,23 @@ export const apply = (ctx, config) => {
44
44
  const mirror = createMirror(memoryDir);
45
45
  const service = createService({ store, mirror, config: cfg });
46
46
 
47
+ // Recall-layer receipt: when searchMemories runs with recordRecall=true, the
48
+ // retrieval scene (query/mode/topK/threshold + candidates) is persisted to
49
+ // recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
50
+ // Best-effort: a failed recall write must never break the search.
51
+ service.setRecallRecorder((recall) => {
52
+ try {
53
+ store.saveRecallRun({
54
+ query: recall.query,
55
+ mode: recall.mode,
56
+ topK: recall.topK,
57
+ threshold: recall.threshold ?? null,
58
+ candidates: recall.candidates ?? [],
59
+ created_at: recall.createdAt
60
+ });
61
+ } catch { /* non-fatal: recall recording is bookkeeping */ }
62
+ });
63
+
47
64
  // User-configurable settings (profile, rules) and custom commands share the
48
65
  // same SQLite file but live in dedicated tables, isolated from memories.
49
66
  const settings = createSettings(store.db);
package/lib/service.js CHANGED
@@ -16,6 +16,13 @@ export function createService({ store, mirror, config, onWrite }) {
16
16
  let vectorIndex = null;
17
17
  let reranker = null;
18
18
 
19
+ // Optional recall recorder, installed via setRecallRecorder after creation.
20
+ // When searchMemories is called with recordRecall=true it receives the
21
+ // actual merged recall scene (candidates + scores + source + threshold) so
22
+ // the retrieval layer can be audited/replayed — the sibling of the dream
23
+ // judgment-layer audit trail (dream_runs).
24
+ let recallRecorder = null;
25
+
19
26
  // Transaction nesting depth. Inside service.transaction the per-mutation side
20
27
  // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
21
28
  // never leaves the mirror file diverged from the database; transaction()
@@ -43,7 +50,7 @@ export function createService({ store, mirror, config, onWrite }) {
43
50
  const out = [];
44
51
  for (const s of scored) {
45
52
  const c = byId.get(s.id);
46
- if (c) { out.push({ ...c, score: s.score }); if (out.length >= topK) break; }
53
+ if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
47
54
  }
48
55
  return out.length ? out : candidates.slice(0, topK);
49
56
  } catch {
@@ -78,15 +85,16 @@ export function createService({ store, mirror, config, onWrite }) {
78
85
  return base * (0.5 + (row.importance ?? 3) / 10);
79
86
  }
80
87
 
81
- async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
88
+ async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = {}) {
82
89
  const q = String(query ?? "").trim();
83
90
  if (!q) return [];
84
91
  const lim = topK > 0 ? topK : 20;
85
92
 
86
93
  // Keyword results, decorated with a score so they can be weight-blended
87
- // with vector results and reported uniformly.
94
+ // with vector results and reported uniformly. source tracks where each
95
+ // candidate came from for the recall layer receipt.
88
96
  const rawKeyword = store.search(q, { limit: lim });
89
- const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q) }));
97
+ const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
90
98
  const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
91
99
  let vector = [];
92
100
  if (wantVector && embedder) {
@@ -100,7 +108,7 @@ export function createService({ store, mirror, config, onWrite }) {
100
108
  const hits = vectorIndex
101
109
  ? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
102
110
  : store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
103
- vector = hits.map((m) => ({ ...m, vector: true }));
111
+ vector = hits.map((m) => ({ ...m, vector: true, source: "vector" }));
104
112
  }
105
113
  } catch { /* vector unavailable: keep keyword results */ }
106
114
  }
@@ -147,10 +155,34 @@ export function createService({ store, mirror, config, onWrite }) {
147
155
  }
148
156
 
149
157
  merged = merged.slice(0, lim);
150
- if (useRerank && reranker && merged.length) {
151
- return rerankCandidates(q, merged, lim);
158
+ const result = useRerank && reranker && merged.length
159
+ ? await rerankCandidates(q, merged, lim)
160
+ : merged;
161
+
162
+ // Recall layer receipt: with recordRecall on, hand the actual merged
163
+ // candidate list (id/title/content/score/source) to the injected recorder
164
+ // before returning, making the retrieval scene replayable — the sibling of
165
+ // the dream judgment-layer audit trail. Recorder failures must never break
166
+ // the search itself.
167
+ if (recordRecall && recallRecorder) {
168
+ try {
169
+ recallRecorder({
170
+ query: q,
171
+ mode,
172
+ topK: lim,
173
+ threshold: threshold ?? null,
174
+ candidates: result.map((m) => ({
175
+ id: m.id,
176
+ title: m.title,
177
+ content: m.content,
178
+ score: m.score ?? null,
179
+ source: m.source ?? "keyword"
180
+ })),
181
+ createdAt: new Date().toISOString()
182
+ });
183
+ } catch { /* recall receipt is best effort */ }
152
184
  }
153
- return merged;
185
+ return result;
154
186
  }
155
187
 
156
188
  /**
@@ -355,6 +387,7 @@ export function createService({ store, mirror, config, onWrite }) {
355
387
  setEmbedder(emb) { embedder = emb; },
356
388
  setVectorIndex(vi) { vectorIndex = vi; },
357
389
  setReranker(rn) { reranker = rn; },
390
+ setRecallRecorder(fn) { recallRecorder = fn; },
358
391
  searchMemories,
359
392
  // passthroughs used by tools and api layers; mutations keep the mirror in sync
360
393
  search: (q, o) => store.search(q, o),
@@ -441,6 +474,11 @@ export function createService({ store, mirror, config, onWrite }) {
441
474
  // dream scheduler that just recorded the run.
442
475
  saveDreamRun: (run) => store.saveDreamRun(run),
443
476
  getDreamRun: (id) => store.getDreamRun(id),
444
- listDreamRuns: (opts) => store.listDreamRuns(opts)
477
+ listDreamRuns: (opts) => store.listDreamRuns(opts),
478
+ // Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
479
+ // audit write, never a write-hook-triggering memory mutation).
480
+ saveReceipt: (r) => store.saveReceipt(r),
481
+ getReceipt: (id) => store.getReceipt(id),
482
+ listReceipts: (opts) => store.listReceipts(opts)
445
483
  };
446
484
  }