@modusensus/dsh-mneme 0.3.7 → 0.3.9

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-443%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-450%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -18,6 +18,11 @@
18
18
  - **SQLite 主存储**:`~/.dsh/memory/memory.db`,`node:sqlite` 内置,零原生依赖
19
19
  - **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
20
20
  - **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
21
+ - **镜像同步状态机(v0.3.6+)**:mirror 与主库强一致,用 `generation`(期望轮次)/ `applied_generation`(已应用轮次)建模同步债务
22
+ - 业务写操作在**自身事务内原子递增** desired generation——崩溃在 COMMIT 后、渲染前,重启也能凭 durable 债务恢复,绝不静默跳过(v0.3.8)
23
+ - `generation` 用 SQLite 原子语句递增,多进程并发零丢失;带 `CHECK` 上界,负数/溢出拒绝
24
+ - 逐 type 记录 `committed / failed / pending` 回执,健康端点区分 `ok / degraded / unknown`
25
+ - 状态写失败不静默:同步失败落日志并留债务,重启自动收敛
21
26
 
22
27
  ### 模型工具(7 个)
23
28
 
@@ -106,6 +111,29 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
106
111
 
107
112
  > 📖 详见 [实体结构化记忆设计](docs/ENTITIES.md) · [语义增强架构](docs/SEMANTIC.md) · [本地模型部署指南](docs/LOCAL_MODEL.md) · [从 v0.1 升级说明](docs/MIGRATION.md)
108
113
 
114
+ ## 🆕 最近版本亮点
115
+
116
+ | 版本 | 亮点 |
117
+ |------|------|
118
+ | **v0.3.9** | 修复第三方审计 4 项 FAIL:CAS 同事务原子化、Mirror 降级回执透传、逐 type 物理终态收敛、Generation 强整数校验与并发初始化稳定化 |
119
+ | **v0.3.8** | audit peer 复验 6 项运行时阻断全部修复:desired generation 同事务原子递增(崩溃窗口不再静默跳过)、同步失败不静默、原子 generation 增量(多进程零丢失)、逐 type committed/failed/pending 回执、读取失败显式 unknown、generation 上界/负数 CHECK |
120
+ | **v0.3.7** | 启动竞态修复:人工编辑 md 镜像后重启向量重建失败(回灌移入 init 就绪后 + scheduleEmbed 就绪门) |
121
+ | **v0.3.6** | mirror 同步状态机:generation/applied_generation 债务建模、F-NEW-03 mirror 健康状态、持久 dirty + 启动 recoverMirror |
122
+ | **v0.3.0** | 记忆基因:实体/属性/关系三表 + 时间轴 + 实体搜索 + autoDream supersedes |
123
+
124
+ ## 🗺️ 进化路线图
125
+
126
+ | 版本 | 状态 | 主题 | 说明 |
127
+ |------|------|------|------|
128
+ | v0.2.x | ✅ 完成 | 语义增强 + 反思更新 | 本地 Embedding/Rerank/聚类、`failure_memories` 失败追踪 |
129
+ | v0.3.0 | ✅ 完成 | 记忆基因 | entities/attrs/relations 三表 + 时间轴 + 实体搜索 |
130
+ | v0.3.6–0.3.8 | ✅ 完成 | 镜像一致性 + 审计加固 | generation 同步状态机、audit peer 6 项运行时阻断修复、450 测试全绿 |
131
+ | v0.3.9 | ✅ 完成 | 审计加固 A/B/D/F | compareAndUpdate 同事务原子性、degraded 回执、逐 type 物理终态、整数 fail-closed、并发初始化稳定 |
132
+ | **v0.4.0** | ⏳ 规划 | 反思性成长 | 纠错双向回流(改记忆同时反思"为什么记错/召回错")+ 规则演进(从 failure 提炼规律注入系统提示)+ 自适应参数 |
133
+ | **v0.5.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
134
+
135
+ > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为 v0.4.0 铺好路。
136
+
109
137
  ## 📦 安装
110
138
 
111
139
  ### 前置条件
@@ -243,7 +271,7 @@ src/
243
271
  lib/
244
272
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
245
273
  └── *.js # src 的同步分发产物
246
- test/ # 443 个 node:test 测试(含审计与三轴线压测不变量)
274
+ test/ # 450 个 node:test 测试(含审计与三轴线压测不变量)
247
275
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
248
276
  ```
249
277
 
@@ -252,7 +280,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
252
280
  ```bash
253
281
  cd dsh-mneme
254
282
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
255
- npm test # 运行 443 个测试
283
+ npm test # 运行 450 个测试
256
284
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
257
285
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
258
286
  ```
package/lib/api.js CHANGED
@@ -289,6 +289,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
289
289
  sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
290
290
  return;
291
291
  }
292
+ // Real read failure surfaces as dirty === null (peer blocker 5): report
293
+ // unknown explicitly instead of collapsing into a false "ok"/"degraded".
294
+ if (state.dirty === null) {
295
+ sendJson(res, 200, {
296
+ mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
297
+ });
298
+ return;
299
+ }
292
300
  // Sanitized: boolean dirty + coarse status only; error string is mapped to
293
301
  // a bounded code, never echoed verbatim.
294
302
  let code = null;
@@ -346,7 +354,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
346
354
  });
347
355
 
348
356
  return {
349
- routes: 7,
357
+ routes: 9,
350
358
  dispose: () => {
351
359
  for (const dispose of disposers) dispose();
352
360
  }
package/lib/mirror.js CHANGED
@@ -128,21 +128,33 @@ export function createMirror(dir) {
128
128
  for (const m of memories) {
129
129
  (byType[m.type] ??= []).push(m);
130
130
  }
131
+ // Per-type physical outcomes (audit peer D): a failed write for one type
132
+ // must not abort the whole render. Each type is written (or pruned) in its
133
+ // own try/catch and the result reported so the caller can persist per-type
134
+ // committed/failed receipts — a file that was already written is a real
135
+ // physical commit even when a sibling type errors.
136
+ const results = {};
131
137
  for (const type of Object.keys(TYPE_FILE)) {
132
- const file = filePath(type);
133
- const items = (byType[type] ?? [])
134
- .slice()
135
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
136
- if (items.length === 0) {
137
- // no memories of this type: drop any stale mirror file so deleted
138
- // memories do not "resurrect" via readHumanEdits
139
- rmSync(file, { force: true });
140
- continue;
138
+ try {
139
+ const file = filePath(type);
140
+ const items = (byType[type] ?? [])
141
+ .slice()
142
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
143
+ if (items.length === 0) {
144
+ // no memories of this type: drop any stale mirror file so deleted
145
+ // memories do not "resurrect" via readHumanEdits
146
+ rmSync(file, { force: true });
147
+ } else {
148
+ const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
149
+ const body = items.map(renderMemory).join("\n");
150
+ writeFileSync(file, header + body, "utf8");
151
+ }
152
+ results[type] = { ok: true };
153
+ } catch (error) {
154
+ results[type] = { ok: false, error: error?.message ?? String(error) };
141
155
  }
142
- const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
143
- const body = items.map(renderMemory).join("\n");
144
- writeFileSync(file, header + body, "utf8");
145
156
  }
157
+ return results;
146
158
  }
147
159
 
148
160
  return { filePath, sync, readHumanEdits };
package/lib/service.js CHANGED
@@ -384,10 +384,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
384
384
  throw error;
385
385
  } finally {
386
386
  txDepth--;
387
- try {
388
- syncMirror();
389
- } catch (error) {
390
- logger?.warn?.("syncMirror failed after transaction:", error);
387
+ // Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
388
+ // debt was already recorded by markMirrorDirty inside syncMirror, so a
389
+ // restart recovers — but the operator must see it now, not after restart.
390
+ const syncResult = syncMirror();
391
+ if (!syncResult?.success && !syncResult?.deferred) {
392
+ logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
391
393
  }
392
394
  notifyWrite();
393
395
  }
@@ -408,7 +410,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
408
410
  tags: memory.tags ?? existing.tags,
409
411
  title: memory.title ?? existing.title
410
412
  });
411
- syncMirror();
413
+ afterSync("write");
412
414
  notifyWrite();
413
415
  scheduleEmbed(merged);
414
416
  return { action: "merged", memory: merged };
@@ -421,7 +423,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
421
423
  importance: memory.importance ?? 3,
422
424
  source: memory.source ?? "manual"
423
425
  });
424
- syncMirror();
426
+ afterSync("write");
425
427
  notifyWrite();
426
428
  scheduleEmbed(created);
427
429
  scheduleEntityExtraction(created);
@@ -481,7 +483,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
481
483
  }
482
484
  }
483
485
  if (applied) {
484
- syncMirror();
486
+ afterSync("write");
485
487
  notifyWrite();
486
488
  }
487
489
  return applied;
@@ -577,16 +579,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
577
579
  // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
578
580
  // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
579
581
  function syncMirror() {
580
- if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
582
+ if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
581
583
  const now = new Date().toISOString();
582
584
  let gen;
583
585
  try {
584
- // 绑定本次期望轮次,必须在任何渲染之前,避免制造幽灵债务
585
- const state = store.incrementGeneration();
586
- gen = state.generation;
586
+ // desired generation 已在业务写事务中原子递增(peer blocker 1);这里
587
+ // 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration
588
+ const state = store.getMirrorState();
589
+ gen = state?.generation ?? 0;
587
590
  } catch (stateError) {
588
- logger?.warn?.("syncMirror: incrementGeneration failed:", stateError);
589
- return;
591
+ logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
592
+ return { success: false, error: stateError?.message ?? String(stateError) };
590
593
  }
591
594
  // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
592
595
  // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
@@ -600,43 +603,89 @@ export function createService({ store, mirror, config, onWrite, logger }) {
600
603
  }
601
604
  }
602
605
 
603
- // 全量渲染
604
- mirror.sync(reconcileHumanEdits(list));
605
-
606
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截
607
- try {
608
- store.markMirrorCleanForGeneration(gen, now);
609
- } catch (stateError) {
610
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
606
+ // Per-type physical outcome (audit peer D): mirror.sync writes each type
607
+ // file independently and reports per-type success/failure. A type whose
608
+ // file was physically committed must be marked committed even when a
609
+ // sibling type errors the old code batch-failed every type on any error,
610
+ // leaving committed files mislabeled as failed and masking partial state.
611
+ // Absent entries (a type with no memories) count as success: sync prunes
612
+ // the stale file, which is itself a completed physical state.
613
+ let allOk = true;
614
+ const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
615
+ for (const type of Object.keys(TYPE_FILE)) {
616
+ const r = results[type];
617
+ const ok = !r || r.ok === true;
618
+ if (!ok) allOk = false;
619
+ try {
620
+ if (ok) {
621
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
622
+ } else {
623
+ store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
624
+ }
625
+ } catch (stateError) {
626
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
627
+ }
611
628
  }
612
- // 逐 type 标记为 clean
613
- for (const type of coveredTypes) {
629
+
630
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
631
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
632
+ if (allOk) {
614
633
  try {
615
- store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
634
+ store.markMirrorCleanForGeneration(gen, now);
616
635
  } catch (stateError) {
617
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
636
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
637
+ return { success: false, error: stateError?.message ?? String(stateError) };
618
638
  }
639
+ return { success: true };
619
640
  }
641
+
642
+ // 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
643
+ // 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
644
+ const failedTypes = Object.entries(results)
645
+ .filter(([, r]) => r && r.ok === false)
646
+ .map(([t]) => t);
647
+ try {
648
+ store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
649
+ } catch (stateError) {
650
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
651
+ }
652
+ return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
620
653
  } catch (error) {
621
654
  const errMsg = error?.message ?? String(error);
622
655
  logger?.warn?.("syncMirror failed:", error);
623
656
  try {
624
- // 债务绑定到新的一轮(desired generation +1)
657
+ // 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
658
+ // generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
625
659
  store.markMirrorDirty(errMsg, now);
626
660
  } catch (stateError) {
627
661
  logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
628
662
  }
629
- // 逐 type 标记为 dirty(applied_gen 不动)
663
+ // 逐 type 标记为 failed(applied_gen 不动)
630
664
  for (const type of coveredTypes) {
631
665
  try {
632
- store.setTypeStatus(type, { dirty: true, last_error: errMsg });
666
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
633
667
  } catch (stateError) {
634
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
668
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
635
669
  }
636
670
  }
671
+ return { success: false, error: errMsg };
637
672
  }
638
673
  }
639
674
 
675
+ // afterSync: run syncMirror and surface a failure to the operator instead of
676
+ // swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
677
+ // been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
678
+ // but the calling write path must not report clean while the mirror is
679
+ // known-stale. Returns the sync result so the caller can attach an explicit
680
+ // degraded/pending receipt to its return value instead of faking success.
681
+ function afterSync(label) {
682
+ const r = syncMirror();
683
+ if (!r?.success && !r?.deferred) {
684
+ logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
685
+ }
686
+ return r;
687
+ }
688
+
640
689
  // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
641
690
  // (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
642
691
  // generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
@@ -718,10 +767,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
718
767
  success_at: state.success_at ?? null
719
768
  };
720
769
  } catch (error) {
721
- // fail-safe:状态读取失败也不向外抛
770
+ // fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
771
+ // 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
722
772
  logger?.warn?.("getMirrorHealth failed:", error);
723
773
  return {
724
- dirty: false,
774
+ dirty: null,
725
775
  last_error: error?.message ?? String(error),
726
776
  last_attempt: null,
727
777
  success_at: null
@@ -780,7 +830,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
780
830
  getById: (id) => store.getById(id),
781
831
  remove: (id) => {
782
832
  store.remove(id);
783
- syncMirror();
833
+ afterSync("write");
784
834
  notifyWrite();
785
835
  },
786
836
  update: (id, p, ctx = {}) => {
@@ -806,9 +856,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
806
856
  memory_id: id
807
857
  });
808
858
  }
809
- syncMirror();
859
+ const sync = afterSync("write");
810
860
  notifyWrite();
811
861
  scheduleEmbed(updated);
862
+ // Audit peer B: when the mirror sync failed, the store write landed but
863
+ // the mirror did not converge — return an explicit degraded receipt rather
864
+ // than a plain success. Non-enumerable so existing deepEqual assertions on
865
+ // the memory shape keep passing.
866
+ if (!sync?.success && !sync?.deferred) {
867
+ Object.defineProperty(updated, "_mirror", {
868
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
869
+ enumerable: false,
870
+ configurable: true
871
+ });
872
+ }
812
873
  return updated;
813
874
  },
814
875
  // Compare-and-set update: applies the patch only when the row still carries
@@ -835,19 +896,27 @@ export function createService({ store, mirror, config, onWrite, logger }) {
835
896
  memory_id: id
836
897
  });
837
898
  }
838
- syncMirror();
899
+ const sync = afterSync("write");
839
900
  notifyWrite();
840
901
  scheduleEmbed(updated);
902
+ // Audit peer B: mirror sync failure on a CAS write must surface too.
903
+ if (!sync?.success && !sync?.deferred) {
904
+ Object.defineProperty(updated, "_mirror", {
905
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
906
+ enumerable: false,
907
+ configurable: true
908
+ });
909
+ }
841
910
  return updated;
842
911
  },
843
912
  setForget: (id, f) => {
844
913
  const updated = store.setForget(id, f);
845
- syncMirror();
914
+ afterSync("write");
846
915
  return updated;
847
916
  },
848
917
  setArchived: (id, f) => {
849
918
  const updated = store.setArchived(id, f);
850
- syncMirror();
919
+ afterSync("write");
851
920
  return updated;
852
921
  },
853
922
  // autoDream audit trail: passthroughs deliberately bypass write hooks —