@modusensus/dsh-mneme 0.3.0 → 0.3.2

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/lib/api.js CHANGED
@@ -268,6 +268,24 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
268
268
  }
269
269
  });
270
270
 
271
+ // --- health: mirror sync state (F-NEW-03) ---
272
+ register({
273
+ kind: "exact",
274
+ path: "/api/dsh-mneme/health",
275
+ handler(req, res) {
276
+ 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
+ });
283
+ } catch {
284
+ sendJson(res, 500, { error: "internal" });
285
+ }
286
+ }
287
+ });
288
+
271
289
  // --- custom commands ---
272
290
  register({
273
291
  kind: "exact",
@@ -140,7 +140,7 @@ async function resolveEntity(entity, store) {
140
140
  return created.id;
141
141
  }
142
142
 
143
- export async function extractEntities(memory, { store, config, callLLM }) {
143
+ export async function extractEntities(memory, { store, config, callLLM, logger }) {
144
144
  try {
145
145
  if (!memory || !memory.content) {
146
146
  return { ok: false, error: "Invalid memory: missing content" };
@@ -181,7 +181,7 @@ export async function extractEntities(memory, { store, config, callLLM }) {
181
181
  resolvedEntities.push({ ...entity, entity_id: entityId });
182
182
  } catch (err) {
183
183
  skipCount++;
184
- console.warn(`[extractor] Failed to resolve entity "${entity.name}":`, err.message);
184
+ logger?.warn?.(`[extractor] Failed to resolve entity "${entity.name}":`, err.message);
185
185
  }
186
186
  }
187
187
 
@@ -201,7 +201,7 @@ export async function extractEntities(memory, { store, config, callLLM }) {
201
201
  attrs.push(saved);
202
202
  } catch (err) {
203
203
  skipCount++;
204
- console.warn(`[extractor] Failed to save attr "${attr.key}" for entity "${entity.name}":`, err.message);
204
+ logger?.warn?.(`[extractor] Failed to save attr "${attr.key}" for entity "${entity.name}":`, err.message);
205
205
  }
206
206
  }
207
207
  }
@@ -224,7 +224,7 @@ export async function extractEntities(memory, { store, config, callLLM }) {
224
224
  savedRelations.push(saved);
225
225
  } catch (err) {
226
226
  skipCount++;
227
- console.warn(`[extractor] Failed to save relation "${rel.from} -> ${rel.to}":`, err.message);
227
+ logger?.warn?.(`[extractor] Failed to save relation "${rel.from} -> ${rel.to}":`, err.message);
228
228
  }
229
229
  }
230
230
 
package/lib/index.js CHANGED
@@ -43,7 +43,13 @@ export const apply = (ctx, config) => {
43
43
  store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
44
44
  } catch { /* non-fatal */ }
45
45
  const mirror = createMirror(memoryDir);
46
- const service = createService({ store, mirror, config: cfg });
46
+ const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
47
+
48
+ // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
49
+ // a safe re-render at boot so a stale mirror converges without needing a
50
+ // business write. Bounded: single attempt; on failure dirty stays for the
51
+ // next boot. Never throws.
52
+ service.recoverMirror();
47
53
 
48
54
  // Recall-layer receipt: when searchMemories runs with recordRecall=true, the
49
55
  // retrieval scene (query/mode/topK/threshold + candidates) is persisted to
@@ -196,7 +202,7 @@ export const apply = (ctx, config) => {
196
202
  return text;
197
203
  };
198
204
  service.setEntityExtractor((memory) =>
199
- extractEntities(memory, { store, config: cfg, callLLM: streamEntityText })
205
+ extractEntities(memory, { store, config: cfg, callLLM: streamEntityText, logger: ctx.logger })
200
206
  .catch((err) => {
201
207
  ctx.logger?.warn?.(`[dsh-mneme] entity extraction failed: ${String(err)}`);
202
208
  return { ok: false, error: String(err) };
package/lib/service.js CHANGED
@@ -3,7 +3,7 @@ import { TYPE_FILE } from "./mirror.js";
3
3
 
4
4
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
5
5
 
6
- export function createService({ store, mirror, config, onWrite }) {
6
+ export function createService({ store, mirror, config, onWrite, logger }) {
7
7
  // Optional dream scheduler hook, installed via setDreamHook after creation
8
8
  // (the scheduler holds a reference back to the service, so it cannot be
9
9
  // passed in the constructor). Fired on the same write events as onWrite.
@@ -56,10 +56,10 @@ export function createService({ store, mirror, config, onWrite }) {
56
56
  if (!config.entityExtractionEnabled || !entityExtractor) return;
57
57
  try {
58
58
  entityExtractor(memory).catch((err) => {
59
- console.warn("entity extraction failed:", err);
59
+ logger?.warn?.("entity extraction failed:", err);
60
60
  });
61
61
  } catch (err) {
62
- console.warn("entity extraction failed:", err);
62
+ logger?.warn?.("entity extraction failed:", err);
63
63
  }
64
64
  }
65
65
 
@@ -306,7 +306,7 @@ export function createService({ store, mirror, config, onWrite }) {
306
306
  try {
307
307
  syncMirror();
308
308
  } catch (error) {
309
- console.warn("syncMirror failed after transaction:", error);
309
+ logger?.warn?.("syncMirror failed after transaction:", error);
310
310
  }
311
311
  notifyWrite();
312
312
  }
@@ -483,17 +483,123 @@ export function createService({ store, mirror, config, onWrite }) {
483
483
  * non-forgotten memories are mirrored: forgotten entries must not reach the
484
484
  * human-editable file (a human "edit" could otherwise resurrect them).
485
485
  */
486
+ // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
487
+ // 失败写 store.markMirrorDirty(dirty=1 + last_error + last_attempt),成功写
488
+ // store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
489
+ // try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
486
490
  function syncMirror() {
487
491
  if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
492
+ const now = new Date().toISOString();
488
493
  try {
489
494
  mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
495
+ // 成功:写 clean 状态
496
+ try {
497
+ store.markMirrorClean(now);
498
+ } catch (stateError) {
499
+ // markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
500
+ logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
501
+ }
502
+ } catch (error) {
503
+ // 同步失败:写 dirty 状态
504
+ const errMsg = error?.message ?? String(error);
505
+ logger?.warn?.("syncMirror failed:", error);
506
+ try {
507
+ store.markMirrorDirty(errMsg, now);
508
+ } catch (stateError) {
509
+ // markMirrorDirty 失败同样不能向外抛出
510
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
511
+ }
512
+ }
513
+ }
514
+
515
+ // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
516
+ // (F-NEW-03)。dirty=true → 有界重试(最多 3 次,立即重试)重跑 syncMirror
517
+ // 收敛镜像;某次成功(dirty 变 0)立即停止。返回 { recovered, error } 供
518
+ // index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
519
+ function recoverMirror() {
520
+ const MAX_ATTEMPTS = 3;
521
+ let lastError = null;
522
+ let recovered = false;
523
+
524
+ try {
525
+ const state = store.getMirrorState();
526
+ if (!state?.dirty) {
527
+ // 本来就干净:无需恢复,视为成功
528
+ return { recovered: true, error: null };
529
+ }
530
+
531
+ // dirty 状态:最多尝试 3 次 sync
532
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
533
+ try {
534
+ syncMirror(); // syncMirror 内部已 catch,不会向外抛
535
+ const currentState = store.getMirrorState();
536
+ if (!currentState?.dirty) {
537
+ // 成功:镜像已收敛为 clean
538
+ recovered = true;
539
+ lastError = null;
540
+ break;
541
+ }
542
+ // 仍 dirty:记录最后一次错误供重试耗尽后上报
543
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
544
+ } catch (syncError) {
545
+ // syncMirror 理论不抛,fail-safe 兜底
546
+ const errMsg = syncError?.message ?? String(syncError);
547
+ logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
548
+ lastError = errMsg;
549
+ }
550
+ }
551
+
552
+ if (!recovered) {
553
+ logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
554
+ } else {
555
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
556
+ }
557
+ } catch (error) {
558
+ // fail-safe:任何意外异常不向外抛
559
+ lastError = error?.message ?? String(error);
560
+ logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
561
+ }
562
+
563
+ return { recovered, error: recovered ? null : lastError };
564
+ }
565
+
566
+ // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
567
+ // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
568
+ function getMirrorHealth() {
569
+ try {
570
+ const state = store.getMirrorState();
571
+ if (!state) {
572
+ // 无状态行:返回安全默认值
573
+ return {
574
+ dirty: false,
575
+ last_error: null,
576
+ last_attempt: null,
577
+ success_at: null
578
+ };
579
+ }
580
+ return {
581
+ dirty: Boolean(state.dirty),
582
+ last_error: state.last_error ?? null,
583
+ last_attempt: state.last_attempt ?? null,
584
+ success_at: state.success_at ?? null
585
+ };
490
586
  } catch (error) {
491
- console.warn("syncMirror failed:", error);
587
+ // fail-safe:状态读取失败也不向外抛
588
+ logger?.warn?.("getMirrorHealth failed:", error);
589
+ return {
590
+ dirty: false,
591
+ last_error: error?.message ?? String(error),
592
+ last_attempt: null,
593
+ success_at: null
594
+ };
492
595
  }
493
596
  }
494
597
 
495
598
  return {
496
599
  saveWithDedupe,
600
+ recoverMirror,
601
+ getMirrorHealth,
602
+ getMirrorState: () => store.getMirrorState(),
497
603
  injectCandidates,
498
604
  mergeHumanEdits,
499
605
  toApiList,
package/lib/store.js CHANGED
@@ -164,6 +164,18 @@ CREATE TABLE IF NOT EXISTS entity_relations (
164
164
  CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity);
165
165
  CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity);
166
166
  CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
167
+
168
+ -- mirror 渲染状态 (F-NEW-03): 单行持久记录 mirror 同步失败/成功状态,使
169
+ -- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
170
+ -- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
171
+ -- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
172
+ CREATE TABLE IF NOT EXISTS mirror_state (
173
+ id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
174
+ dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
175
+ last_error TEXT, -- 最近失败原因
176
+ last_attempt TEXT, -- 最近尝试时间(ISO)
177
+ success_at TEXT -- 最近成功时间(ISO)
178
+ );
167
179
  `;
168
180
 
169
181
  const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
@@ -323,6 +335,19 @@ function toRelation(row) {
323
335
  };
324
336
  }
325
337
 
338
+ function toMirrorState(row) {
339
+ if (!row) {
340
+ return { dirty: false, last_error: null, last_attempt: null, success_at: null };
341
+ }
342
+ return {
343
+ id: row.id,
344
+ dirty: row.dirty === 1,
345
+ last_error: row.last_error,
346
+ last_attempt: row.last_attempt,
347
+ success_at: row.success_at
348
+ };
349
+ }
350
+
326
351
  function parseJsonArray(raw) {
327
352
  try {
328
353
  const arr = JSON.parse(raw);
@@ -1084,6 +1109,69 @@ export function createStore(path) {
1084
1109
  ).all(entityId, entityId).map(toRelation);
1085
1110
  }
1086
1111
 
1112
+ // --- mirror sync state (F-NEW-03) -----------------------------------------
1113
+
1114
+ /**
1115
+ * 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).
1119
+ */
1120
+ 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)
1124
+ );
1125
+ if (keys.length === 0) {
1126
+ db.prepare(
1127
+ "INSERT INTO mirror_state (id) VALUES ('main') ON CONFLICT(id) DO NOTHING"
1128
+ ).run();
1129
+ return getMirrorState();
1130
+ }
1131
+ // 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
1132
+ // 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
+ );
1139
+ db.prepare(
1140
+ `INSERT INTO mirror_state (id, ${cols}) VALUES ('main', ${placeholders})
1141
+ ON CONFLICT(id) DO UPDATE SET ${updates}`
1142
+ ).run(...values);
1143
+ return getMirrorState();
1144
+ }
1145
+
1146
+ /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null} when absent. */
1147
+ function getMirrorState() {
1148
+ const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
1149
+ return toMirrorState(row);
1150
+ }
1151
+
1152
+ /** Convenience: mark the mirror dirty after a failed sync (dirty=1 + last_error + last_attempt). */
1153
+ function markMirrorDirty(error, now) {
1154
+ return setMirrorState({
1155
+ dirty: 1,
1156
+ last_error: error,
1157
+ last_attempt: now ?? nowIso()
1158
+ });
1159
+ }
1160
+
1161
+ /** Convenience: mark the mirror clean after a successful sync (dirty=0 + last_error=null + success_at). */
1162
+ function markMirrorClean(now) {
1163
+ return setMirrorState({
1164
+ dirty: 0,
1165
+ last_error: null,
1166
+ success_at: now ?? nowIso()
1167
+ });
1168
+ }
1169
+
1170
+ /** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
1171
+ function clearMirrorDirty() {
1172
+ return setMirrorState({ dirty: 0, last_error: null });
1173
+ }
1174
+
1087
1175
  return {
1088
1176
  db,
1089
1177
  count,
@@ -1132,6 +1220,11 @@ export function createStore(path) {
1132
1220
  saveRelation,
1133
1221
  migrateAttrsToMemory,
1134
1222
  getRelations,
1223
+ setMirrorState,
1224
+ getMirrorState,
1225
+ markMirrorDirty,
1226
+ markMirrorClean,
1227
+ clearMirrorDirty,
1135
1228
  close() {
1136
1229
  db.close();
1137
1230
  }
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.0",
4
+ "version": "0.3.2",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -268,6 +268,24 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
268
268
  }
269
269
  });
270
270
 
271
+ // --- health: mirror sync state (F-NEW-03) ---
272
+ register({
273
+ kind: "exact",
274
+ path: "/api/dsh-mneme/health",
275
+ handler(req, res) {
276
+ 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
+ });
283
+ } catch {
284
+ sendJson(res, 500, { error: "internal" });
285
+ }
286
+ }
287
+ });
288
+
271
289
  // --- custom commands ---
272
290
  register({
273
291
  kind: "exact",
@@ -140,7 +140,7 @@ async function resolveEntity(entity, store) {
140
140
  return created.id;
141
141
  }
142
142
 
143
- export async function extractEntities(memory, { store, config, callLLM }) {
143
+ export async function extractEntities(memory, { store, config, callLLM, logger }) {
144
144
  try {
145
145
  if (!memory || !memory.content) {
146
146
  return { ok: false, error: "Invalid memory: missing content" };
@@ -181,7 +181,7 @@ export async function extractEntities(memory, { store, config, callLLM }) {
181
181
  resolvedEntities.push({ ...entity, entity_id: entityId });
182
182
  } catch (err) {
183
183
  skipCount++;
184
- console.warn(`[extractor] Failed to resolve entity "${entity.name}":`, err.message);
184
+ logger?.warn?.(`[extractor] Failed to resolve entity "${entity.name}":`, err.message);
185
185
  }
186
186
  }
187
187
 
@@ -201,7 +201,7 @@ export async function extractEntities(memory, { store, config, callLLM }) {
201
201
  attrs.push(saved);
202
202
  } catch (err) {
203
203
  skipCount++;
204
- console.warn(`[extractor] Failed to save attr "${attr.key}" for entity "${entity.name}":`, err.message);
204
+ logger?.warn?.(`[extractor] Failed to save attr "${attr.key}" for entity "${entity.name}":`, err.message);
205
205
  }
206
206
  }
207
207
  }
@@ -224,7 +224,7 @@ export async function extractEntities(memory, { store, config, callLLM }) {
224
224
  savedRelations.push(saved);
225
225
  } catch (err) {
226
226
  skipCount++;
227
- console.warn(`[extractor] Failed to save relation "${rel.from} -> ${rel.to}":`, err.message);
227
+ logger?.warn?.(`[extractor] Failed to save relation "${rel.from} -> ${rel.to}":`, err.message);
228
228
  }
229
229
  }
230
230
 
package/src/index.js CHANGED
@@ -43,7 +43,13 @@ export const apply = (ctx, config) => {
43
43
  store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
44
44
  } catch { /* non-fatal */ }
45
45
  const mirror = createMirror(memoryDir);
46
- const service = createService({ store, mirror, config: cfg });
46
+ const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
47
+
48
+ // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
49
+ // a safe re-render at boot so a stale mirror converges without needing a
50
+ // business write. Bounded: single attempt; on failure dirty stays for the
51
+ // next boot. Never throws.
52
+ service.recoverMirror();
47
53
 
48
54
  // Recall-layer receipt: when searchMemories runs with recordRecall=true, the
49
55
  // retrieval scene (query/mode/topK/threshold + candidates) is persisted to
@@ -196,7 +202,7 @@ export const apply = (ctx, config) => {
196
202
  return text;
197
203
  };
198
204
  service.setEntityExtractor((memory) =>
199
- extractEntities(memory, { store, config: cfg, callLLM: streamEntityText })
205
+ extractEntities(memory, { store, config: cfg, callLLM: streamEntityText, logger: ctx.logger })
200
206
  .catch((err) => {
201
207
  ctx.logger?.warn?.(`[dsh-mneme] entity extraction failed: ${String(err)}`);
202
208
  return { ok: false, error: String(err) };
package/src/service.js CHANGED
@@ -3,7 +3,7 @@ import { TYPE_FILE } from "./mirror.js";
3
3
 
4
4
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
5
5
 
6
- export function createService({ store, mirror, config, onWrite }) {
6
+ export function createService({ store, mirror, config, onWrite, logger }) {
7
7
  // Optional dream scheduler hook, installed via setDreamHook after creation
8
8
  // (the scheduler holds a reference back to the service, so it cannot be
9
9
  // passed in the constructor). Fired on the same write events as onWrite.
@@ -56,10 +56,10 @@ export function createService({ store, mirror, config, onWrite }) {
56
56
  if (!config.entityExtractionEnabled || !entityExtractor) return;
57
57
  try {
58
58
  entityExtractor(memory).catch((err) => {
59
- console.warn("entity extraction failed:", err);
59
+ logger?.warn?.("entity extraction failed:", err);
60
60
  });
61
61
  } catch (err) {
62
- console.warn("entity extraction failed:", err);
62
+ logger?.warn?.("entity extraction failed:", err);
63
63
  }
64
64
  }
65
65
 
@@ -306,7 +306,7 @@ export function createService({ store, mirror, config, onWrite }) {
306
306
  try {
307
307
  syncMirror();
308
308
  } catch (error) {
309
- console.warn("syncMirror failed after transaction:", error);
309
+ logger?.warn?.("syncMirror failed after transaction:", error);
310
310
  }
311
311
  notifyWrite();
312
312
  }
@@ -483,17 +483,123 @@ export function createService({ store, mirror, config, onWrite }) {
483
483
  * non-forgotten memories are mirrored: forgotten entries must not reach the
484
484
  * human-editable file (a human "edit" could otherwise resurrect them).
485
485
  */
486
+ // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
487
+ // 失败写 store.markMirrorDirty(dirty=1 + last_error + last_attempt),成功写
488
+ // store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
489
+ // try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
486
490
  function syncMirror() {
487
491
  if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
492
+ const now = new Date().toISOString();
488
493
  try {
489
494
  mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
495
+ // 成功:写 clean 状态
496
+ try {
497
+ store.markMirrorClean(now);
498
+ } catch (stateError) {
499
+ // markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
500
+ logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
501
+ }
502
+ } catch (error) {
503
+ // 同步失败:写 dirty 状态
504
+ const errMsg = error?.message ?? String(error);
505
+ logger?.warn?.("syncMirror failed:", error);
506
+ try {
507
+ store.markMirrorDirty(errMsg, now);
508
+ } catch (stateError) {
509
+ // markMirrorDirty 失败同样不能向外抛出
510
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
511
+ }
512
+ }
513
+ }
514
+
515
+ // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
516
+ // (F-NEW-03)。dirty=true → 有界重试(最多 3 次,立即重试)重跑 syncMirror
517
+ // 收敛镜像;某次成功(dirty 变 0)立即停止。返回 { recovered, error } 供
518
+ // index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
519
+ function recoverMirror() {
520
+ const MAX_ATTEMPTS = 3;
521
+ let lastError = null;
522
+ let recovered = false;
523
+
524
+ try {
525
+ const state = store.getMirrorState();
526
+ if (!state?.dirty) {
527
+ // 本来就干净:无需恢复,视为成功
528
+ return { recovered: true, error: null };
529
+ }
530
+
531
+ // dirty 状态:最多尝试 3 次 sync
532
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
533
+ try {
534
+ syncMirror(); // syncMirror 内部已 catch,不会向外抛
535
+ const currentState = store.getMirrorState();
536
+ if (!currentState?.dirty) {
537
+ // 成功:镜像已收敛为 clean
538
+ recovered = true;
539
+ lastError = null;
540
+ break;
541
+ }
542
+ // 仍 dirty:记录最后一次错误供重试耗尽后上报
543
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
544
+ } catch (syncError) {
545
+ // syncMirror 理论不抛,fail-safe 兜底
546
+ const errMsg = syncError?.message ?? String(syncError);
547
+ logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
548
+ lastError = errMsg;
549
+ }
550
+ }
551
+
552
+ if (!recovered) {
553
+ logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
554
+ } else {
555
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
556
+ }
557
+ } catch (error) {
558
+ // fail-safe:任何意外异常不向外抛
559
+ lastError = error?.message ?? String(error);
560
+ logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
561
+ }
562
+
563
+ return { recovered, error: recovered ? null : lastError };
564
+ }
565
+
566
+ // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
567
+ // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
568
+ function getMirrorHealth() {
569
+ try {
570
+ const state = store.getMirrorState();
571
+ if (!state) {
572
+ // 无状态行:返回安全默认值
573
+ return {
574
+ dirty: false,
575
+ last_error: null,
576
+ last_attempt: null,
577
+ success_at: null
578
+ };
579
+ }
580
+ return {
581
+ dirty: Boolean(state.dirty),
582
+ last_error: state.last_error ?? null,
583
+ last_attempt: state.last_attempt ?? null,
584
+ success_at: state.success_at ?? null
585
+ };
490
586
  } catch (error) {
491
- console.warn("syncMirror failed:", error);
587
+ // fail-safe:状态读取失败也不向外抛
588
+ logger?.warn?.("getMirrorHealth failed:", error);
589
+ return {
590
+ dirty: false,
591
+ last_error: error?.message ?? String(error),
592
+ last_attempt: null,
593
+ success_at: null
594
+ };
492
595
  }
493
596
  }
494
597
 
495
598
  return {
496
599
  saveWithDedupe,
600
+ recoverMirror,
601
+ getMirrorHealth,
602
+ getMirrorState: () => store.getMirrorState(),
497
603
  injectCandidates,
498
604
  mergeHumanEdits,
499
605
  toApiList,