@modusensus/dsh-mneme 0.3.8 → 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
@@ -354,7 +354,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
354
354
  });
355
355
 
356
356
  return {
357
- routes: 7,
357
+ routes: 9,
358
358
  dispose: () => {
359
359
  for (const dispose of disposers) dispose();
360
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
@@ -603,26 +603,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
603
603
  }
604
604
  }
605
605
 
606
- // 全量渲染
607
- mirror.sync(reconcileHumanEdits(list));
608
-
609
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
610
- // 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
611
- try {
612
- store.markMirrorCleanForGeneration(gen, now);
613
- } catch (stateError) {
614
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
615
- return { success: false, error: stateError?.message ?? String(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
+ }
616
628
  }
617
- // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
618
- for (const type of coveredTypes) {
629
+
630
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
631
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
632
+ if (allOk) {
619
633
  try {
620
- store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
634
+ store.markMirrorCleanForGeneration(gen, now);
621
635
  } catch (stateError) {
622
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
636
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
637
+ return { success: false, error: stateError?.message ?? String(stateError) };
623
638
  }
639
+ return { success: true };
624
640
  }
625
- return { success: true };
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(", ")}` };
626
653
  } catch (error) {
627
654
  const errMsg = error?.message ?? String(error);
628
655
  logger?.warn?.("syncMirror failed:", error);
@@ -646,14 +673,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
646
673
  }
647
674
 
648
675
  // afterSync: run syncMirror and surface a failure to the operator instead of
649
- // swallowing it (peer blocker 2). The mirror debt has already been persisted
650
- // by markMirrorDirty inside syncMirror, so a restart recovers — but the
651
- // calling write path must not report clean while the mirror is known-stale.
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.
652
681
  function afterSync(label) {
653
682
  const r = syncMirror();
654
683
  if (!r?.success && !r?.deferred) {
655
684
  logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
656
685
  }
686
+ return r;
657
687
  }
658
688
 
659
689
  // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
@@ -826,9 +856,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
826
856
  memory_id: id
827
857
  });
828
858
  }
829
- afterSync("write");
859
+ const sync = afterSync("write");
830
860
  notifyWrite();
831
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
+ }
832
873
  return updated;
833
874
  },
834
875
  // Compare-and-set update: applies the patch only when the row still carries
@@ -855,9 +896,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
855
896
  memory_id: id
856
897
  });
857
898
  }
858
- afterSync("write");
899
+ const sync = afterSync("write");
859
900
  notifyWrite();
860
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
+ }
861
910
  return updated;
862
911
  },
863
912
  setForget: (id, f) => {
package/lib/store.js CHANGED
@@ -180,8 +180,8 @@ CREATE TABLE IF NOT EXISTS mirror_state (
180
180
  last_error TEXT, -- 最近失败原因
181
181
  last_attempt TEXT, -- 最近尝试时间(ISO)
182
182
  success_at TEXT, -- 最近成功时间(ISO)
183
- generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991), -- 期望的同步轮次(desired)
184
- applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991), -- 已成功应用的轮次
183
+ generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
184
+ applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
185
185
  type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
186
186
  );
187
187
  `;
@@ -391,11 +391,14 @@ function parseJsonArray(raw) {
391
391
 
392
392
  export function createStore(path) {
393
393
  const db = new DatabaseSync(path);
394
- db.exec("PRAGMA journal_mode = WAL;");
395
- // Concurrent writers (peer probe: 8 independent processes) must wait for the
396
- // write lock instead of failing immediately with SQLITE_BUSY otherwise the
397
- // atomic generation increment loses whole writes, not just increments.
394
+ // Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
395
+ // init). Switching a fresh DB to WAL takes an exclusive lock; when several
396
+ // processes open the same path simultaneously, that lock can fail with
397
+ // SQLITE_BUSY before the timeout is armed. With the timeout installed first,
398
+ // the WAL transition (and every later write) blocks and retries instead of
399
+ // failing outright, so concurrent init converges to a stable 447/447.
398
400
  db.exec("PRAGMA busy_timeout = 5000;");
401
+ db.exec("PRAGMA journal_mode = WAL;");
399
402
  db.exec(SCHEMA);
400
403
 
401
404
  // Schema migrations for legacy databases (idempotent).
@@ -426,6 +429,24 @@ export function createStore(path) {
426
429
  db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
427
430
  }
428
431
 
432
+ // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
433
+ // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
434
+ // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
435
+ // round — so surface it as a hard error on open instead of silently reading it
436
+ // as a coherent generation. Fail-closed: the operator must repair or reset the
437
+ // state row rather than continue with a lie.
438
+ for (const col of ["generation", "applied_generation"]) {
439
+ const bad = db.prepare(
440
+ `SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
441
+ ).get();
442
+ if (bad) {
443
+ throw new RangeError(
444
+ `mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
445
+ `repair or reset the row before opening this database`
446
+ );
447
+ }
448
+ }
449
+
429
450
  // Per-instance monotonic timestamp guard: consecutive writes within the same
430
451
  // millisecond must still produce strictly increasing timestamps (test asserts
431
452
  // updated_at != created_at). State lives in the store closure, not module scope.
@@ -552,24 +573,35 @@ export function createStore(path) {
552
573
  const embedding = patch.embedding !== undefined
553
574
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
554
575
  : existing.embedding ?? null;
555
- const result = db.prepare(
556
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
557
- WHERE id=? AND updated_at=?`
558
- ).run(
559
- type,
560
- patch.title ?? existing.title,
561
- patch.content ?? existing.content,
562
- JSON.stringify(patch.tags ?? existing.tags),
563
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
564
- patch.source !== undefined ? patch.source : (existing.source ?? null),
565
- embedding,
566
- now,
567
- id,
568
- expectedUpdatedAt
569
- );
570
- if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
571
- // Only bump desired generation on a successful CAS — a miss writes nothing.
572
- runAtomically(() => { incrementGeneration(); });
576
+ // The CAS UPDATE and the desired-generation bump must commit together (audit
577
+ // peer A): if the UPDATE autocommits first and the process dies before the
578
+ // increment, the store is mutated while generation == applied_generation and
579
+ // dirty == false — recoverMirror sees no debt and the mirror stays stale.
580
+ // Wrapping both in one transaction means a CAS miss rolls back cleanly too
581
+ // (no write, no generation bump).
582
+ let applied = false;
583
+ runAtomically(() => {
584
+ const result = db.prepare(
585
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
586
+ WHERE id=? AND updated_at=?`
587
+ ).run(
588
+ type,
589
+ patch.title ?? existing.title,
590
+ patch.content ?? existing.content,
591
+ JSON.stringify(patch.tags ?? existing.tags),
592
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
593
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
594
+ embedding,
595
+ now,
596
+ id,
597
+ expectedUpdatedAt
598
+ );
599
+ if (result.changes === 0) return; // CAS miss: a concurrent write won
600
+ // Only bump desired generation on a successful CAS — a miss writes nothing.
601
+ incrementGeneration();
602
+ applied = true;
603
+ });
604
+ if (!applied) return undefined;
573
605
  return getById(id);
574
606
  }
575
607
 
@@ -1223,8 +1255,15 @@ export function createStore(path) {
1223
1255
  if (key === "dirty") {
1224
1256
  value = value ? 1 : 0;
1225
1257
  } else if (key === "generation" || key === "applied_generation") {
1226
- value = Math.trunc(Number(value));
1227
- if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1258
+ // Fail-closed integer enforcement (audit peer F): never truncate. A
1259
+ // fractional value like 1.5 previously passed the JS gate via
1260
+ // Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
1261
+ // dirty legacy row could carry a non-integer generation that reads as a
1262
+ // coherent applied round. Reject non-integers outright — the caller must
1263
+ // pass a whole number, and a stale dirty value stays visible instead of
1264
+ // being "repaired" into a misleading clean integer.
1265
+ value = Number(value);
1266
+ if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1228
1267
  throw new RangeError(`mirror_state.${key} out of range: ${value}`);
1229
1268
  }
1230
1269
  } else if (key === "type_status" && value != null && typeof value !== "string") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
- "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.3.8",
3
+ "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
+ "version": "0.3.9",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -104,9 +104,11 @@ console.log(`记忆目录:${memDir}\n`);
104
104
  // 1. 装载检查
105
105
  console.log("【1】插件装载");
106
106
  const checks = [];
107
- checks.push(["注册 6 个模型工具", registeredTools.length === 6]);
107
+ checks.push(["注册 7 个模型工具", registeredTools.length === 7]);
108
108
  checks.push(["注册 2 个注入上下文", injectContexts.length === 2 && injectContexts[0].name === "memory"]);
109
- checks.push(["注册 9 条 API 路由", apiRoutes.length === 9]);
109
+ // 契约是 9 条 exact 路由;prefix fallback(/api/dsh-mneme 404)是兜底,
110
+ // 不计入路由数。
111
+ checks.push(["注册 9 条 API 路由", apiRoutes.filter((r) => r.kind === "exact").length === 9]);
110
112
  for (const [label, ok] of checks) console.log(` ${ok ? "✅" : "❌"} ${label}`);
111
113
  if (!checks.every(([, ok]) => ok)) { console.log("\n装载检查失败,中止。"); process.exit(1); }
112
114
  console.log(` 工具:${registeredTools.map((t) => t.name).join(", ")}\n`);
package/src/api.js CHANGED
@@ -354,7 +354,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
354
354
  });
355
355
 
356
356
  return {
357
- routes: 7,
357
+ routes: 9,
358
358
  dispose: () => {
359
359
  for (const dispose of disposers) dispose();
360
360
  }
package/src/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/src/service.js CHANGED
@@ -603,26 +603,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
603
603
  }
604
604
  }
605
605
 
606
- // 全量渲染
607
- mirror.sync(reconcileHumanEdits(list));
608
-
609
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
610
- // 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
611
- try {
612
- store.markMirrorCleanForGeneration(gen, now);
613
- } catch (stateError) {
614
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
615
- return { success: false, error: stateError?.message ?? String(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
+ }
616
628
  }
617
- // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
618
- for (const type of coveredTypes) {
629
+
630
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
631
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
632
+ if (allOk) {
619
633
  try {
620
- store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
634
+ store.markMirrorCleanForGeneration(gen, now);
621
635
  } catch (stateError) {
622
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
636
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
637
+ return { success: false, error: stateError?.message ?? String(stateError) };
623
638
  }
639
+ return { success: true };
624
640
  }
625
- return { success: true };
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(", ")}` };
626
653
  } catch (error) {
627
654
  const errMsg = error?.message ?? String(error);
628
655
  logger?.warn?.("syncMirror failed:", error);
@@ -646,14 +673,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
646
673
  }
647
674
 
648
675
  // afterSync: run syncMirror and surface a failure to the operator instead of
649
- // swallowing it (peer blocker 2). The mirror debt has already been persisted
650
- // by markMirrorDirty inside syncMirror, so a restart recovers — but the
651
- // calling write path must not report clean while the mirror is known-stale.
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.
652
681
  function afterSync(label) {
653
682
  const r = syncMirror();
654
683
  if (!r?.success && !r?.deferred) {
655
684
  logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
656
685
  }
686
+ return r;
657
687
  }
658
688
 
659
689
  // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
@@ -826,9 +856,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
826
856
  memory_id: id
827
857
  });
828
858
  }
829
- afterSync("write");
859
+ const sync = afterSync("write");
830
860
  notifyWrite();
831
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
+ }
832
873
  return updated;
833
874
  },
834
875
  // Compare-and-set update: applies the patch only when the row still carries
@@ -855,9 +896,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
855
896
  memory_id: id
856
897
  });
857
898
  }
858
- afterSync("write");
899
+ const sync = afterSync("write");
859
900
  notifyWrite();
860
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
+ }
861
910
  return updated;
862
911
  },
863
912
  setForget: (id, f) => {
package/src/store.js CHANGED
@@ -180,8 +180,8 @@ CREATE TABLE IF NOT EXISTS mirror_state (
180
180
  last_error TEXT, -- 最近失败原因
181
181
  last_attempt TEXT, -- 最近尝试时间(ISO)
182
182
  success_at TEXT, -- 最近成功时间(ISO)
183
- generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991), -- 期望的同步轮次(desired)
184
- applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991), -- 已成功应用的轮次
183
+ generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
184
+ applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
185
185
  type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
186
186
  );
187
187
  `;
@@ -391,11 +391,14 @@ function parseJsonArray(raw) {
391
391
 
392
392
  export function createStore(path) {
393
393
  const db = new DatabaseSync(path);
394
- db.exec("PRAGMA journal_mode = WAL;");
395
- // Concurrent writers (peer probe: 8 independent processes) must wait for the
396
- // write lock instead of failing immediately with SQLITE_BUSY otherwise the
397
- // atomic generation increment loses whole writes, not just increments.
394
+ // Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
395
+ // init). Switching a fresh DB to WAL takes an exclusive lock; when several
396
+ // processes open the same path simultaneously, that lock can fail with
397
+ // SQLITE_BUSY before the timeout is armed. With the timeout installed first,
398
+ // the WAL transition (and every later write) blocks and retries instead of
399
+ // failing outright, so concurrent init converges to a stable 447/447.
398
400
  db.exec("PRAGMA busy_timeout = 5000;");
401
+ db.exec("PRAGMA journal_mode = WAL;");
399
402
  db.exec(SCHEMA);
400
403
 
401
404
  // Schema migrations for legacy databases (idempotent).
@@ -426,6 +429,24 @@ export function createStore(path) {
426
429
  db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
427
430
  }
428
431
 
432
+ // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
433
+ // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
434
+ // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
435
+ // round — so surface it as a hard error on open instead of silently reading it
436
+ // as a coherent generation. Fail-closed: the operator must repair or reset the
437
+ // state row rather than continue with a lie.
438
+ for (const col of ["generation", "applied_generation"]) {
439
+ const bad = db.prepare(
440
+ `SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
441
+ ).get();
442
+ if (bad) {
443
+ throw new RangeError(
444
+ `mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
445
+ `repair or reset the row before opening this database`
446
+ );
447
+ }
448
+ }
449
+
429
450
  // Per-instance monotonic timestamp guard: consecutive writes within the same
430
451
  // millisecond must still produce strictly increasing timestamps (test asserts
431
452
  // updated_at != created_at). State lives in the store closure, not module scope.
@@ -552,24 +573,35 @@ export function createStore(path) {
552
573
  const embedding = patch.embedding !== undefined
553
574
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
554
575
  : existing.embedding ?? null;
555
- const result = db.prepare(
556
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
557
- WHERE id=? AND updated_at=?`
558
- ).run(
559
- type,
560
- patch.title ?? existing.title,
561
- patch.content ?? existing.content,
562
- JSON.stringify(patch.tags ?? existing.tags),
563
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
564
- patch.source !== undefined ? patch.source : (existing.source ?? null),
565
- embedding,
566
- now,
567
- id,
568
- expectedUpdatedAt
569
- );
570
- if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
571
- // Only bump desired generation on a successful CAS — a miss writes nothing.
572
- runAtomically(() => { incrementGeneration(); });
576
+ // The CAS UPDATE and the desired-generation bump must commit together (audit
577
+ // peer A): if the UPDATE autocommits first and the process dies before the
578
+ // increment, the store is mutated while generation == applied_generation and
579
+ // dirty == false — recoverMirror sees no debt and the mirror stays stale.
580
+ // Wrapping both in one transaction means a CAS miss rolls back cleanly too
581
+ // (no write, no generation bump).
582
+ let applied = false;
583
+ runAtomically(() => {
584
+ const result = db.prepare(
585
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
586
+ WHERE id=? AND updated_at=?`
587
+ ).run(
588
+ type,
589
+ patch.title ?? existing.title,
590
+ patch.content ?? existing.content,
591
+ JSON.stringify(patch.tags ?? existing.tags),
592
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
593
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
594
+ embedding,
595
+ now,
596
+ id,
597
+ expectedUpdatedAt
598
+ );
599
+ if (result.changes === 0) return; // CAS miss: a concurrent write won
600
+ // Only bump desired generation on a successful CAS — a miss writes nothing.
601
+ incrementGeneration();
602
+ applied = true;
603
+ });
604
+ if (!applied) return undefined;
573
605
  return getById(id);
574
606
  }
575
607
 
@@ -1223,8 +1255,15 @@ export function createStore(path) {
1223
1255
  if (key === "dirty") {
1224
1256
  value = value ? 1 : 0;
1225
1257
  } else if (key === "generation" || key === "applied_generation") {
1226
- value = Math.trunc(Number(value));
1227
- if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1258
+ // Fail-closed integer enforcement (audit peer F): never truncate. A
1259
+ // fractional value like 1.5 previously passed the JS gate via
1260
+ // Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
1261
+ // dirty legacy row could carry a non-integer generation that reads as a
1262
+ // coherent applied round. Reject non-integers outright — the caller must
1263
+ // pass a whole number, and a stale dirty value stays visible instead of
1264
+ // being "repaired" into a misleading clean integer.
1265
+ value = Number(value);
1266
+ if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1228
1267
  throw new RangeError(`mirror_state.${key} out of range: ${value}`);
1229
1268
  }
1230
1269
  } else if (key === "type_status" && value != null && typeof value !== "string") {
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { EventEmitter } from "node:events";
@@ -464,3 +464,36 @@ test("V0.3.6-F1: 旧库(v0.3.5 5 列)打开自动 ALTER 加 3 列,不丢
464
464
  rmSync(dir, { recursive: true, force: true });
465
465
  }
466
466
  });
467
+
468
+ test("v0.3.9-D: mirror 逐 type 物理终态——兄弟 type 失败不误标已提交 type", () => {
469
+ const { dir, store, mirror, service } = setup();
470
+ try {
471
+ // 注入逐 type 故障:project 写成功、decision 抛错(模拟 EISDIR),其余 type 正常。
472
+ // 对应审计 D:project 文件已物理提交、decision 失败,状态必须逐 type 记录——
473
+ // 不能像旧逻辑那样整体批量标 failed。
474
+ const original = mirror.sync.bind(mirror);
475
+ mirror.sync = (memories) => {
476
+ const results = original(memories);
477
+ results.decision = { ok: false, error: "EISDIR: decision.md is a directory" };
478
+ return results;
479
+ };
480
+ // project 与 decision 都有真实记忆 → 触发逐 type 渲染
481
+ store.save({ id: "mem-project", type: "project", title: "已提交", content: "物理写入", importance: 3, tags: [] });
482
+ store.save({ id: "mem-decision", type: "decision", title: "写失败", content: "此 type 失败", importance: 3, tags: [] });
483
+ // 直接调 service 内部 syncMirror(通过一次写触发)
484
+ service.saveWithDedupe({ type: "project", title: "触发", content: "sync", importance: 3 });
485
+
486
+ const ts = store.getTypeStatus();
487
+ // project 物理提交 → 必须 committed,不能被 decision 失败拖成 failed
488
+ assert.equal(ts.project.status, "committed", "物理已提交的 type 必须标记 committed");
489
+ assert.equal(ts.decision.status, "failed", "失败的 type 必须标记 failed");
490
+ // 部分失败 = 未完全收敛 → dirty 必须持久
491
+ assert.equal(store.getMirrorState().dirty, true, "部分 type 失败必须持久 dirty");
492
+ // 镜像里 project 文件真实存在(物理终态已落地,不是整体失败)
493
+ const projectFile = mirror.filePath("project");
494
+ assert.ok(existsSync(projectFile), "project 镜像文件必须已物理写入");
495
+ } finally {
496
+ store.close();
497
+ rmSync(dir, { recursive: true, force: true });
498
+ }
499
+ });
@@ -146,3 +146,45 @@ test("peer-D: generation 上界与负数拒绝", () => {
146
146
  rmSync(dir, { recursive: true, force: true });
147
147
  }
148
148
  });
149
+
150
+ test("v0.3.9-A: compareAndUpdate 的 CAS UPDATE 与 generation 同事务——miss 不得递增", () => {
151
+ const { dir, store } = setup();
152
+ try {
153
+ const saved = store.save({ type: "project", title: "CAS 原子", content: "v0" });
154
+ const before = store.getById(saved.id);
155
+ const genBefore = store.getMirrorState().generation;
156
+
157
+ // 成功 CAS:业务写入 + generation 递增必须一次提交(同事务)
158
+ const updated = store.compareAndUpdate(saved.id, before.updated_at, { content: "v1" });
159
+ assert.ok(updated, "当前版本 CAS 必须成功");
160
+ const genAfterOk = store.getMirrorState().generation;
161
+ assert.equal(genAfterOk, genBefore + 1, "成功 CAS 必须恰好递增一次 generation");
162
+
163
+ // miss CAS:不写任何东西,generation 也不得递增
164
+ const stale = store.getById(saved.id).updated_at; // v1 的 token
165
+ store.compareAndUpdate(saved.id, before.updated_at, { content: "v2" }); // 用旧 token → miss
166
+ assert.equal(store.getById(saved.id).content, "v1", "miss 不得改数据");
167
+ assert.equal(store.getMirrorState().generation, genAfterOk,
168
+ "CAS miss 不得递增 generation(UPDATE 与 increment 必须同事务)");
169
+ } finally {
170
+ store.close();
171
+ rmSync(dir, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test("v0.3.9-F: generation 非整数必须拒绝(不得截断)", () => {
176
+ const { dir, store } = setup();
177
+ try {
178
+ // 审计 peer F:1.5 这类小数此前被 Math.trunc 截断 + SQLite CHECK 接受 → 静默脏值。
179
+ // fail-closed:JS 与 SQL 统一只接受整数。
180
+ assert.throws(() => store.setMirrorState({ generation: 1.5 }), RangeError, "小数 generation 必须拒绝");
181
+ assert.throws(() => store.setMirrorState({ applied_generation: -1.5 }), RangeError, "负数小数必须拒绝");
182
+ assert.throws(() => store.setMirrorState({ generation: Number.MAX_SAFE_INTEGER + 0.5 }), RangeError, "超界小数必须拒绝");
183
+ // 整数仍正常
184
+ const s = store.setMirrorState({ generation: 7 });
185
+ assert.equal(s.generation, 7, "整数 generation 正常");
186
+ } finally {
187
+ store.close();
188
+ rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });