@modusensus/dsh-mneme 0.3.5 → 0.3.6

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-429%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-443%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -243,7 +243,7 @@ src/
243
243
  lib/
244
244
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
245
245
  └── *.js # src 的同步分发产物
246
- test/ # 429 个 node:test 测试(含审计与三轴线压测不变量)
246
+ test/ # 443 个 node:test 测试(含审计与三轴线压测不变量)
247
247
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
248
248
  ```
249
249
 
@@ -252,7 +252,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
252
252
  ```bash
253
253
  cd dsh-mneme
254
254
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
255
- npm test # 运行 429 个测试
255
+ npm test # 运行 443 个测试
256
256
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
257
257
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
258
258
  ```
package/lib/api.js CHANGED
@@ -268,21 +268,43 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
268
268
  }
269
269
  });
270
270
 
271
- // --- health: mirror sync state (F-NEW-03) ---
271
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
272
+ // Auth-gated; only returns a sanitized error code (never raw last_error which
273
+ // may leak paths/token-like strings/internal hosts). On state read failure it
274
+ // reports unknown/degraded (fail-closed) instead of a false dirty=false.
272
275
  register({
273
276
  kind: "exact",
274
277
  path: "/api/dsh-mneme/health",
275
278
  handler(req, res) {
279
+ if (!requireAuth(req, res, apiToken)) return;
280
+ let state = null;
276
281
  try {
277
- const state = service.getMirrorHealth?.() ?? null;
278
- sendJson(res, 200, {
279
- mirror: state
280
- ? { dirty: state.dirty === true, last_error: state.last_error ?? null, last_attempt: state.last_attempt ?? null, success_at: state.success_at ?? null }
281
- : null
282
- });
282
+ state = service.getMirrorHealth?.() ?? null;
283
283
  } catch {
284
- sendJson(res, 500, { error: "internal" });
284
+ // read failure is itself a health signal: do not report a false clean
285
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
286
+ return;
285
287
  }
288
+ if (!state) {
289
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
290
+ return;
291
+ }
292
+ // Sanitized: boolean dirty + coarse status only; error string is mapped to
293
+ // a bounded code, never echoed verbatim.
294
+ let code = null;
295
+ if (state.last_error) {
296
+ const e = String(state.last_error);
297
+ code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
298
+ }
299
+ sendJson(res, 200, {
300
+ mirror: {
301
+ dirty: state.dirty === true,
302
+ status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
303
+ last_error: code,
304
+ last_attempt: state.last_attempt ?? null,
305
+ success_at: state.success_at ?? null
306
+ }
307
+ });
286
308
  }
287
309
  });
288
310
 
package/lib/service.js CHANGED
@@ -110,7 +110,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
110
110
 
111
111
  /**
112
112
  * Search for memories attached to a named entity (v0.3.0 Phase 3).
113
- * 合并优先级(桉桉确认):entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
113
+ * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
114
114
  * attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
115
115
  * @param {string} entityName
116
116
  * @param {object} [options]
@@ -511,38 +511,82 @@ export function createService({ store, mirror, config, onWrite, logger }) {
511
511
  * human-editable file (a human "edit" could otherwise resurrect them).
512
512
  */
513
513
  // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
514
- // 失败写 store.markMirrorDirtydirty=1 + last_error + last_attempt),成功写
515
- // store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
516
- // try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
514
+ // v0.3.6audit peer 4 阻断):
515
+ // - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
516
+ // markMirrorCleanForGeneration(gen, now) CAS/fence dirty——旧 worker
517
+ // (gen 已过期)不会误清另一 worker 未恢复的故障债务;
518
+ // - 失败写 markMirrorDirty(递增 desired 绑定新债务),下次 recover 恢复;
519
+ // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
520
+ // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
517
521
  function syncMirror() {
518
522
  if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
519
523
  const now = new Date().toISOString();
524
+ let gen;
520
525
  try {
521
- mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
522
- // 成功:写 clean 状态
526
+ // 绑定本次期望轮次,必须在任何渲染之前,避免制造幽灵债务
527
+ const state = store.incrementGeneration();
528
+ gen = state.generation;
529
+ } catch (stateError) {
530
+ logger?.warn?.("syncMirror: incrementGeneration failed:", stateError);
531
+ return;
532
+ }
533
+ // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
534
+ // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
535
+ const coveredTypes = new Set();
536
+ try {
537
+ // 预先获取本次要覆盖的 type 集合(只调一次 store.list)
538
+ const list = store.list({ limit: 500, includeForgotten: false });
539
+ for (const memory of list) {
540
+ if (memory?.type && TYPE_FILE[memory.type]) {
541
+ coveredTypes.add(memory.type);
542
+ }
543
+ }
544
+
545
+ // 全量渲染
546
+ mirror.sync(reconcileHumanEdits(list));
547
+
548
+ // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截
523
549
  try {
524
- store.markMirrorClean(now);
550
+ store.markMirrorCleanForGeneration(gen, now);
525
551
  } catch (stateError) {
526
- // markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
527
- logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
552
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
553
+ }
554
+ // 逐 type 标记为 clean
555
+ for (const type of coveredTypes) {
556
+ try {
557
+ store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
558
+ } catch (stateError) {
559
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
560
+ }
528
561
  }
529
562
  } catch (error) {
530
- // 同步失败:写 dirty 状态
531
563
  const errMsg = error?.message ?? String(error);
532
564
  logger?.warn?.("syncMirror failed:", error);
533
565
  try {
566
+ // 债务绑定到新的一轮(desired generation +1)
534
567
  store.markMirrorDirty(errMsg, now);
535
568
  } catch (stateError) {
536
- // markMirrorDirty 失败同样不能向外抛出
537
569
  logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
538
570
  }
571
+ // 逐 type 标记为 dirty(applied_gen 不动)
572
+ for (const type of coveredTypes) {
573
+ try {
574
+ store.setTypeStatus(type, { dirty: true, last_error: errMsg });
575
+ } catch (stateError) {
576
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
577
+ }
578
+ }
539
579
  }
540
580
  }
541
581
 
542
582
  // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
543
- // (F-NEW-03)。dirty=true 有界重试(最多 3 次,立即重试)重跑 syncMirror
544
- // 收敛镜像;某次成功(dirty 0)立即停止。返回 { recovered, error } 供
545
- // index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
583
+ // (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
584
+ // generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
585
+ // 窗口(DB 提交后、markMirrorDirty/clean 前进程退出 dirty=false
586
+ // generation 不一致)也能被捕获。有界重试(最多 3 次)重跑 syncMirror 收敛;
587
+ // 某次成功后 dirty=false 且无更新债务(generation <= applied_generation)
588
+ // 立即停止。返回 { recovered, error } 供 index.js 启动 / api.js health 判断。
589
+ // 一切 fail-safe,绝不向外抛。
546
590
  function recoverMirror() {
547
591
  const MAX_ATTEMPTS = 3;
548
592
  let lastError = null;
@@ -550,24 +594,29 @@ export function createService({ store, mirror, config, onWrite, logger }) {
550
594
 
551
595
  try {
552
596
  const state = store.getMirrorState();
553
- if (!state?.dirty) {
597
+ // 崩溃窗口检测:dirty 或 generation > applied_generation(COMMIT→dirty 窗口)
598
+ if (!state?.dirty && !(state.generation > state.applied_generation)) {
554
599
  // 本来就干净:无需恢复,视为成功
555
600
  return { recovered: true, error: null };
556
601
  }
557
602
 
558
- // dirty 状态:最多尝试 3 次 sync
603
+ // dirty 或有未应用债务:最多尝试 3 次 sync
559
604
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
560
605
  try {
561
606
  syncMirror(); // syncMirror 内部已 catch,不会向外抛
562
607
  const currentState = store.getMirrorState();
563
- if (!currentState?.dirty) {
564
- // 成功:镜像已收敛为 clean
608
+ // 成功条件:dirty 为 false 且没有更新一轮的债务
609
+ // (generation <= applied_generation,恢复后由 syncMirror 里
610
+ // markMirrorCleanForGeneration 自动把 applied 跟上)
611
+ if (!currentState?.dirty && currentState.generation <= currentState.applied_generation) {
565
612
  recovered = true;
566
613
  lastError = null;
567
614
  break;
568
615
  }
569
- // 仍 dirty:记录最后一次错误供重试耗尽后上报
570
- lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
616
+ // 仍 dirty 或仍有更新债务:记录最后一次错误供重试耗尽后上报。
617
+ // 注意:若别的 worker 又失败产生新债务(dirty true),这是"新债务"
618
+ // 不是本次失败,继续重试直到耗尽次数。
619
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty or has pending debt`;
571
620
  } catch (syncError) {
572
621
  // syncMirror 理论不抛,fail-safe 兜底
573
622
  const errMsg = syncError?.message ?? String(syncError);
@@ -579,7 +628,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
579
628
  if (!recovered) {
580
629
  logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
581
630
  } else {
582
- logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
631
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty/pending state");
583
632
  }
584
633
  } catch (error) {
585
634
  // fail-safe:任何意外异常不向外抛
package/lib/store.js CHANGED
@@ -169,12 +169,20 @@ CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type)
169
169
  -- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
170
170
  -- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
171
171
  -- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
172
+ -- v0.3.6: 新增 generation/applied_generation/type_status —— desired-applied
173
+ -- 建模镜像债务:generation 是期望同步轮次,applied_generation 是已成功应用
174
+ -- 轮次(成功清 dirty 必须 CAS/fence 到具体轮次,旧 worker 不能清新故障),
175
+ -- type_status 逐 type 记录部分成功状态。旧库经 PRAGMA table_info 检查后
176
+ -- ALTER 补列,幂等且不丢数据。
172
177
  CREATE TABLE IF NOT EXISTS mirror_state (
173
178
  id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
174
179
  dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
175
180
  last_error TEXT, -- 最近失败原因
176
181
  last_attempt TEXT, -- 最近尝试时间(ISO)
177
- success_at TEXT -- 最近成功时间(ISO)
182
+ success_at TEXT, -- 最近成功时间(ISO)
183
+ generation INTEGER NOT NULL DEFAULT 0, -- 期望的同步轮次(desired)
184
+ applied_generation INTEGER NOT NULL DEFAULT 0, -- 已成功应用的轮次
185
+ type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
178
186
  );
179
187
  `;
180
188
 
@@ -337,14 +345,33 @@ function toRelation(row) {
337
345
 
338
346
  function toMirrorState(row) {
339
347
  if (!row) {
340
- return { dirty: false, last_error: null, last_attempt: null, success_at: null };
348
+ return {
349
+ dirty: false,
350
+ last_error: null,
351
+ last_attempt: null,
352
+ success_at: null,
353
+ generation: 0,
354
+ applied_generation: 0,
355
+ type_status: {}
356
+ };
357
+ }
358
+ let typeStatus = {};
359
+ if (row.type_status) {
360
+ try {
361
+ typeStatus = JSON.parse(row.type_status) || {};
362
+ } catch {
363
+ typeStatus = {};
364
+ }
341
365
  }
342
366
  return {
343
367
  id: row.id,
344
368
  dirty: row.dirty === 1,
345
369
  last_error: row.last_error,
346
370
  last_attempt: row.last_attempt,
347
- success_at: row.success_at
371
+ success_at: row.success_at,
372
+ generation: Number(row.generation) || 0,
373
+ applied_generation: Number(row.applied_generation) || 0,
374
+ type_status: typeStatus
348
375
  };
349
376
  }
350
377
 
@@ -377,6 +404,19 @@ export function createStore(path) {
377
404
  db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
378
405
  }
379
406
 
407
+ // Legacy mirror_state without v0.3.6 generation columns → add each missing
408
+ // column idempotently (old DBs open cleanly, no data loss).
409
+ const mirrorCols = db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
410
+ if (!mirrorCols.includes("generation")) {
411
+ db.exec("ALTER TABLE mirror_state ADD COLUMN generation INTEGER NOT NULL DEFAULT 0");
412
+ }
413
+ if (!mirrorCols.includes("applied_generation")) {
414
+ db.exec("ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
415
+ }
416
+ if (!mirrorCols.includes("type_status")) {
417
+ db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
418
+ }
419
+
380
420
  // Per-instance monotonic timestamp guard: consecutive writes within the same
381
421
  // millisecond must still produce strictly increasing timestamps (test asserts
382
422
  // updated_at != created_at). State lives in the store closure, not module scope.
@@ -1113,14 +1153,25 @@ export function createStore(path) {
1113
1153
 
1114
1154
  /**
1115
1155
  * Upsert the single mirror_state row (id='main'). patch accepts
1116
- * {dirty?, last_error?, last_attempt?, success_at?} — only the keys present
1117
- * on the object are written, everything else is left untouched. Returns the
1118
- * freshly read state row (default shape when absent).
1156
+ * {dirty?, last_error?, last_attempt?, success_at?, generation?,
1157
+ * applied_generation?, type_status?} only the keys present on the object
1158
+ * are written, everything else is left untouched (partial upsert). type_status
1159
+ * is stored as JSON text (objects are serialized on write), generation /
1160
+ * applied_generation are coerced to non-negative integers. Returns the freshly
1161
+ * read state row (default shape when absent).
1119
1162
  */
1120
1163
  function setMirrorState(patch) {
1121
- const ALLOWED = new Set(["dirty", "last_error", "last_attempt", "success_at"]);
1122
- const keys = Object.keys(patch).filter((key) =>
1123
- ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
1164
+ const ALLOWED = new Set([
1165
+ "dirty",
1166
+ "last_error",
1167
+ "last_attempt",
1168
+ "success_at",
1169
+ "generation",
1170
+ "applied_generation",
1171
+ "type_status"
1172
+ ]);
1173
+ const keys = Object.keys(patch).filter(
1174
+ (key) => ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
1124
1175
  );
1125
1176
  if (keys.length === 0) {
1126
1177
  db.prepare(
@@ -1130,41 +1181,78 @@ export function createStore(path) {
1130
1181
  }
1131
1182
  // 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
1132
1183
  // patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
1133
- const cols = keys.join(", ");
1134
- const placeholders = keys.map(() => "?").join(", ");
1135
- const updates = keys.map((k) => `${k} = excluded.${k}`).join(", ");
1136
- const values = keys.map((key) =>
1137
- key === "dirty" ? (patch[key] ? 1 : 0) : patch[key]
1138
- );
1184
+ const cols = [];
1185
+ const values = [];
1186
+ const updates = [];
1187
+ for (const key of keys) {
1188
+ let value = patch[key];
1189
+ if (key === "dirty") {
1190
+ value = value ? 1 : 0;
1191
+ } else if (key === "generation" || key === "applied_generation") {
1192
+ value = Math.trunc(Number(value)) || 0;
1193
+ } else if (key === "type_status" && value != null && typeof value !== "string") {
1194
+ value = JSON.stringify(value);
1195
+ }
1196
+ cols.push(key);
1197
+ values.push(value);
1198
+ updates.push(`${key} = excluded.${key}`);
1199
+ }
1200
+ const placeholders = cols.map(() => "?").join(", ");
1139
1201
  db.prepare(
1140
- `INSERT INTO mirror_state (id, ${cols}) VALUES ('main', ${placeholders})
1141
- ON CONFLICT(id) DO UPDATE SET ${updates}`
1202
+ `INSERT INTO mirror_state (id, ${cols.join(", ")}) VALUES ('main', ${placeholders})
1203
+ ON CONFLICT(id) DO UPDATE SET ${updates.join(", ")}`
1142
1204
  ).run(...values);
1143
1205
  return getMirrorState();
1144
1206
  }
1145
1207
 
1146
- /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null} when absent. */
1208
+ /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null, generation:0, applied_generation:0, type_status:{}} when absent. */
1147
1209
  function getMirrorState() {
1148
1210
  const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
1149
1211
  return toMirrorState(row);
1150
1212
  }
1151
1213
 
1152
- /** Convenience: mark the mirror dirty after a failed sync (dirty=1 + last_error + last_attempt). */
1214
+ /**
1215
+ * Mark the mirror dirty after a failed sync (dirty=1 + last_error +
1216
+ * last_attempt). v0.3.6: also bumps the desired generation so the debt is
1217
+ * bound to a specific sync round; applied_generation is left untouched
1218
+ * (the round was NOT applied). A stale worker that started earlier cannot
1219
+ * clear this newer debt — only a clean fenced to a generation at least as
1220
+ * recent as this one may.
1221
+ */
1153
1222
  function markMirrorDirty(error, now) {
1223
+ const current = getMirrorState();
1154
1224
  return setMirrorState({
1155
1225
  dirty: 1,
1156
1226
  last_error: error,
1157
- last_attempt: now ?? nowIso()
1227
+ last_attempt: now ?? nowIso(),
1228
+ generation: (current.generation || 0) + 1
1158
1229
  });
1159
1230
  }
1160
1231
 
1161
- /** Convenience: mark the mirror clean after a successful sync (dirty=0 + last_error=null + success_at). */
1232
+ /**
1233
+ * Fenced clean (CAS): mark the mirror clean for a specific generation.
1234
+ * First records that generation `gen` has been applied
1235
+ * (applied_generation = MAX(applied_generation, gen)), then clears dirty only
1236
+ * when the current desired generation has not advanced past gen — a stale
1237
+ * worker cleaning an older round must not wipe a newer failure's debt.
1238
+ * Returns the resulting state (dirty stays set when the fence holds).
1239
+ */
1240
+ function markMirrorCleanForGeneration(gen, now) {
1241
+ const current = getMirrorState();
1242
+ const applied = Math.max(current.applied_generation || 0, gen);
1243
+ const patch = { applied_generation: applied };
1244
+ if (applied >= gen && (current.generation || 0) <= gen) {
1245
+ patch.dirty = 0;
1246
+ patch.last_error = null;
1247
+ patch.success_at = now ?? nowIso();
1248
+ }
1249
+ return setMirrorState(patch);
1250
+ }
1251
+
1252
+ /** Convenience: mark the mirror clean for the current desired generation (backward-compatible with pre-v0.3.6 callers). */
1162
1253
  function markMirrorClean(now) {
1163
- return setMirrorState({
1164
- dirty: 0,
1165
- last_error: null,
1166
- success_at: now ?? nowIso()
1167
- });
1254
+ const current = getMirrorState();
1255
+ return markMirrorCleanForGeneration(current.generation || 0, now);
1168
1256
  }
1169
1257
 
1170
1258
  /** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
@@ -1172,6 +1260,30 @@ export function createStore(path) {
1172
1260
  return setMirrorState({ dirty: 0, last_error: null });
1173
1261
  }
1174
1262
 
1263
+ /**
1264
+ * Record per-type mirror status (partial success bookkeeping). `status` is a
1265
+ * partial patch {dirty?, applied_gen?, last_error?} merged into the existing
1266
+ * entry for `type` (other types untouched). Returns the updated full state.
1267
+ */
1268
+ function setTypeStatus(type, status) {
1269
+ const current = getMirrorState();
1270
+ const statuses = current.type_status || {};
1271
+ statuses[type] = { ...(statuses[type] || {}), ...status };
1272
+ return setMirrorState({ type_status: JSON.stringify(statuses) });
1273
+ }
1274
+
1275
+ /** Per-type mirror status map {type: {dirty, applied_gen, last_error}}, {} when unset. */
1276
+ function getTypeStatus() {
1277
+ const current = getMirrorState();
1278
+ return current.type_status || {};
1279
+ }
1280
+
1281
+ /** Bump the desired generation (new sync round), returning the new state. */
1282
+ function incrementGeneration() {
1283
+ const current = getMirrorState();
1284
+ return setMirrorState({ generation: (current.generation || 0) + 1 });
1285
+ }
1286
+
1175
1287
  return {
1176
1288
  db,
1177
1289
  count,
@@ -1224,7 +1336,11 @@ export function createStore(path) {
1224
1336
  getMirrorState,
1225
1337
  markMirrorDirty,
1226
1338
  markMirrorClean,
1339
+ markMirrorCleanForGeneration,
1227
1340
  clearMirrorDirty,
1341
+ setTypeStatus,
1342
+ getTypeStatus,
1343
+ incrementGeneration,
1228
1344
  close() {
1229
1345
  db.close();
1230
1346
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
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.5",
4
+ "version": "0.3.6",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -268,21 +268,43 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
268
268
  }
269
269
  });
270
270
 
271
- // --- health: mirror sync state (F-NEW-03) ---
271
+ // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
272
+ // Auth-gated; only returns a sanitized error code (never raw last_error which
273
+ // may leak paths/token-like strings/internal hosts). On state read failure it
274
+ // reports unknown/degraded (fail-closed) instead of a false dirty=false.
272
275
  register({
273
276
  kind: "exact",
274
277
  path: "/api/dsh-mneme/health",
275
278
  handler(req, res) {
279
+ if (!requireAuth(req, res, apiToken)) return;
280
+ let state = null;
276
281
  try {
277
- const state = service.getMirrorHealth?.() ?? null;
278
- sendJson(res, 200, {
279
- mirror: state
280
- ? { dirty: state.dirty === true, last_error: state.last_error ?? null, last_attempt: state.last_attempt ?? null, success_at: state.success_at ?? null }
281
- : null
282
- });
282
+ state = service.getMirrorHealth?.() ?? null;
283
283
  } catch {
284
- sendJson(res, 500, { error: "internal" });
284
+ // read failure is itself a health signal: do not report a false clean
285
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
286
+ return;
285
287
  }
288
+ if (!state) {
289
+ sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
290
+ return;
291
+ }
292
+ // Sanitized: boolean dirty + coarse status only; error string is mapped to
293
+ // a bounded code, never echoed verbatim.
294
+ let code = null;
295
+ if (state.last_error) {
296
+ const e = String(state.last_error);
297
+ code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
298
+ }
299
+ sendJson(res, 200, {
300
+ mirror: {
301
+ dirty: state.dirty === true,
302
+ status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
303
+ last_error: code,
304
+ last_attempt: state.last_attempt ?? null,
305
+ success_at: state.success_at ?? null
306
+ }
307
+ });
286
308
  }
287
309
  });
288
310