@modusensus/dsh-mneme 0.3.1 → 0.3.3

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-404%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-429%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -180,7 +180,7 @@ dsh web
180
180
  | `localEmbedBatchSize` | `8` | 本地 embedding 批大小(1-64) |
181
181
  | `ollamaBaseUrl` | `http://localhost:11434` | Ollama 服务地址 |
182
182
  | `ollamaModel` | `nomic-embed-text` | Ollama embedding 模型 |
183
- | `embedModelCacheDir` | 空 | 模型缓存目录(空 = transformers 默认缓存) |
183
+ | `embedModelCacheDir` | 空 | 模型缓存目录(空 = 用户级 `~/.dsh/mneme/models`) |
184
184
  | `embedModelMirror` | `https://hf-mirror.com` | 模型下载镜像源 |
185
185
  | `vectorSearchTopK` | `20` | 向量搜索返回 Top-K |
186
186
  | `vectorSearchThreshold` | `0.65` | 向量搜索相似度阈值 |
@@ -243,7 +243,7 @@ src/
243
243
  lib/
244
244
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
245
245
  └── *.js # src 的同步分发产物
246
- test/ # 404 个 node:test 测试(含审计与三轴线压测不变量)
246
+ test/ # 429 个 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 # 运行 404 个测试
255
+ npm test # 运行 429 个测试
256
256
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
257
257
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
258
258
  ```
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",
package/lib/config.js CHANGED
@@ -42,7 +42,9 @@ export const Config = z.object({
42
42
  ollamaBaseUrl: z.string().default("http://localhost:11434"),
43
43
  ollamaModel: z.string().default("nomic-embed-text"),
44
44
 
45
- // Model download/cache.
45
+ // Model download/cache. When empty (default), models are cached under the
46
+ // user-level path ~/.dsh/mneme/models (resolved in local-embedder/reranker);
47
+ // a non-empty value is used verbatim.
46
48
  embedModelCacheDir: z.string().default(""),
47
49
  embedModelMirror: z.string().default("https://hf-mirror.com"),
48
50
 
package/lib/index.js CHANGED
@@ -45,6 +45,12 @@ export const apply = (ctx, config) => {
45
45
  const mirror = createMirror(memoryDir);
46
46
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
47
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();
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
50
56
  // recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
@@ -3,6 +3,9 @@
3
3
  // old embedding.js logic). All classes share one interface so the orchestrator
4
4
  // can pick a backend by provider name and degrade gracefully on failure.
5
5
  // Methods throw on error — the caller decides the fallback chain.
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
6
9
  const DEFAULT_TIMEOUT_MS = 15000;
7
10
 
8
11
  /** djb2 — stable, fast fingerprint for a provider/model string. */
@@ -47,7 +50,9 @@ export class LocalEmbedder {
47
50
  this._dimension = opts.dimension || 512;
48
51
  this.device = opts.device || "cpu";
49
52
  this.batchSize = opts.batchSize || 8;
50
- this.cacheDir = String(opts.cacheDir ?? "").trim();
53
+ this.cacheDir =
54
+ String(opts.cacheDir ?? "").trim() ||
55
+ path.join(os.homedir(), ".dsh", "mneme", "models");
51
56
  this.useDtype = opts.useDtype || "q8";
52
57
  this.logger = opts.logger ?? null;
53
58
  // Test hook: replace the pipeline factory without touching modules.
package/lib/reranker.js CHANGED
@@ -1,3 +1,6 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
1
4
  // Cross-encoder re-ranker for dsh-mneme recall candidates. Uses
2
5
  // bge-reranker-base through transformers.js: tries the native `rerank` task
3
6
  // first, then the sequence-classification head (sigmoid on the logit delta),
@@ -65,7 +68,9 @@ export class LocalReranker {
65
68
  this.maxCandidates = opts.maxCandidates || 30;
66
69
  this.scoreThreshold = opts.scoreThreshold ?? 0.1;
67
70
  this.device = opts.device || "cpu";
68
- this.cacheDir = String(opts.cacheDir ?? "").trim();
71
+ this.cacheDir =
72
+ String(opts.cacheDir ?? "").trim() ||
73
+ path.join(os.homedir(), ".dsh", "mneme", "models");
69
74
  this.logger = opts.logger ?? null;
70
75
  this.engineFactory = opts.engineFactory || defaultPipelineLoader;
71
76
  // Injectable seam: async (query, passage) => number. When set, init()
package/lib/service.js CHANGED
@@ -391,8 +391,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
391
391
  const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
392
392
  || (patch.content !== undefined && existing.content !== patch.content);
393
393
  if (!hasDiff) continue;
394
- store.update(edit.id, patch);
394
+ // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
395
+ // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
396
+ // 内部 try/catch 吞错,失败不影响主流程。
397
+ const merged = store.update(edit.id, patch);
395
398
  applied++;
399
+ scheduleEmbed(merged);
396
400
  }
397
401
  }
398
402
  if (applied) {
@@ -483,17 +487,123 @@ export function createService({ store, mirror, config, onWrite, logger }) {
483
487
  * non-forgotten memories are mirrored: forgotten entries must not reach the
484
488
  * human-editable file (a human "edit" could otherwise resurrect them).
485
489
  */
490
+ // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
491
+ // 失败写 store.markMirrorDirty(dirty=1 + last_error + last_attempt),成功写
492
+ // store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
493
+ // try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
486
494
  function syncMirror() {
487
495
  if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
496
+ const now = new Date().toISOString();
488
497
  try {
489
498
  mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
499
+ // 成功:写 clean 状态
500
+ try {
501
+ store.markMirrorClean(now);
502
+ } catch (stateError) {
503
+ // markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
504
+ logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
505
+ }
490
506
  } catch (error) {
507
+ // 同步失败:写 dirty 状态
508
+ const errMsg = error?.message ?? String(error);
491
509
  logger?.warn?.("syncMirror failed:", error);
510
+ try {
511
+ store.markMirrorDirty(errMsg, now);
512
+ } catch (stateError) {
513
+ // markMirrorDirty 失败同样不能向外抛出
514
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
515
+ }
516
+ }
517
+ }
518
+
519
+ // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
520
+ // (F-NEW-03)。dirty=true → 有界重试(最多 3 次,立即重试)重跑 syncMirror
521
+ // 收敛镜像;某次成功(dirty 变 0)立即停止。返回 { recovered, error } 供
522
+ // index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
523
+ function recoverMirror() {
524
+ const MAX_ATTEMPTS = 3;
525
+ let lastError = null;
526
+ let recovered = false;
527
+
528
+ try {
529
+ const state = store.getMirrorState();
530
+ if (!state?.dirty) {
531
+ // 本来就干净:无需恢复,视为成功
532
+ return { recovered: true, error: null };
533
+ }
534
+
535
+ // dirty 状态:最多尝试 3 次 sync
536
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
537
+ try {
538
+ syncMirror(); // syncMirror 内部已 catch,不会向外抛
539
+ const currentState = store.getMirrorState();
540
+ if (!currentState?.dirty) {
541
+ // 成功:镜像已收敛为 clean
542
+ recovered = true;
543
+ lastError = null;
544
+ break;
545
+ }
546
+ // 仍 dirty:记录最后一次错误供重试耗尽后上报
547
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
548
+ } catch (syncError) {
549
+ // syncMirror 理论不抛,fail-safe 兜底
550
+ const errMsg = syncError?.message ?? String(syncError);
551
+ logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
552
+ lastError = errMsg;
553
+ }
554
+ }
555
+
556
+ if (!recovered) {
557
+ logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
558
+ } else {
559
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
560
+ }
561
+ } catch (error) {
562
+ // fail-safe:任何意外异常不向外抛
563
+ lastError = error?.message ?? String(error);
564
+ logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
565
+ }
566
+
567
+ return { recovered, error: recovered ? null : lastError };
568
+ }
569
+
570
+ // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
571
+ // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
572
+ function getMirrorHealth() {
573
+ try {
574
+ const state = store.getMirrorState();
575
+ if (!state) {
576
+ // 无状态行:返回安全默认值
577
+ return {
578
+ dirty: false,
579
+ last_error: null,
580
+ last_attempt: null,
581
+ success_at: null
582
+ };
583
+ }
584
+ return {
585
+ dirty: Boolean(state.dirty),
586
+ last_error: state.last_error ?? null,
587
+ last_attempt: state.last_attempt ?? null,
588
+ success_at: state.success_at ?? null
589
+ };
590
+ } catch (error) {
591
+ // fail-safe:状态读取失败也不向外抛
592
+ logger?.warn?.("getMirrorHealth failed:", error);
593
+ return {
594
+ dirty: false,
595
+ last_error: error?.message ?? String(error),
596
+ last_attempt: null,
597
+ success_at: null
598
+ };
492
599
  }
493
600
  }
494
601
 
495
602
  return {
496
603
  saveWithDedupe,
604
+ recoverMirror,
605
+ getMirrorHealth,
606
+ getMirrorState: () => store.getMirrorState(),
497
607
  injectCandidates,
498
608
  mergeHumanEdits,
499
609
  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.1",
4
+ "version": "0.3.3",
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",
package/src/config.js CHANGED
@@ -42,7 +42,9 @@ export const Config = z.object({
42
42
  ollamaBaseUrl: z.string().default("http://localhost:11434"),
43
43
  ollamaModel: z.string().default("nomic-embed-text"),
44
44
 
45
- // Model download/cache.
45
+ // Model download/cache. When empty (default), models are cached under the
46
+ // user-level path ~/.dsh/mneme/models (resolved in local-embedder/reranker);
47
+ // a non-empty value is used verbatim.
46
48
  embedModelCacheDir: z.string().default(""),
47
49
  embedModelMirror: z.string().default("https://hf-mirror.com"),
48
50
 
package/src/index.js CHANGED
@@ -45,6 +45,12 @@ export const apply = (ctx, config) => {
45
45
  const mirror = createMirror(memoryDir);
46
46
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
47
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();
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
50
56
  // recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
@@ -3,6 +3,9 @@
3
3
  // old embedding.js logic). All classes share one interface so the orchestrator
4
4
  // can pick a backend by provider name and degrade gracefully on failure.
5
5
  // Methods throw on error — the caller decides the fallback chain.
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
6
9
  const DEFAULT_TIMEOUT_MS = 15000;
7
10
 
8
11
  /** djb2 — stable, fast fingerprint for a provider/model string. */
@@ -47,7 +50,9 @@ export class LocalEmbedder {
47
50
  this._dimension = opts.dimension || 512;
48
51
  this.device = opts.device || "cpu";
49
52
  this.batchSize = opts.batchSize || 8;
50
- this.cacheDir = String(opts.cacheDir ?? "").trim();
53
+ this.cacheDir =
54
+ String(opts.cacheDir ?? "").trim() ||
55
+ path.join(os.homedir(), ".dsh", "mneme", "models");
51
56
  this.useDtype = opts.useDtype || "q8";
52
57
  this.logger = opts.logger ?? null;
53
58
  // Test hook: replace the pipeline factory without touching modules.
package/src/reranker.js CHANGED
@@ -1,3 +1,6 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
1
4
  // Cross-encoder re-ranker for dsh-mneme recall candidates. Uses
2
5
  // bge-reranker-base through transformers.js: tries the native `rerank` task
3
6
  // first, then the sequence-classification head (sigmoid on the logit delta),
@@ -65,7 +68,9 @@ export class LocalReranker {
65
68
  this.maxCandidates = opts.maxCandidates || 30;
66
69
  this.scoreThreshold = opts.scoreThreshold ?? 0.1;
67
70
  this.device = opts.device || "cpu";
68
- this.cacheDir = String(opts.cacheDir ?? "").trim();
71
+ this.cacheDir =
72
+ String(opts.cacheDir ?? "").trim() ||
73
+ path.join(os.homedir(), ".dsh", "mneme", "models");
69
74
  this.logger = opts.logger ?? null;
70
75
  this.engineFactory = opts.engineFactory || defaultPipelineLoader;
71
76
  // Injectable seam: async (query, passage) => number. When set, init()
package/src/service.js CHANGED
@@ -391,8 +391,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
391
391
  const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
392
392
  || (patch.content !== undefined && existing.content !== patch.content);
393
393
  if (!hasDiff) continue;
394
- store.update(edit.id, patch);
394
+ // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
395
+ // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
396
+ // 内部 try/catch 吞错,失败不影响主流程。
397
+ const merged = store.update(edit.id, patch);
395
398
  applied++;
399
+ scheduleEmbed(merged);
396
400
  }
397
401
  }
398
402
  if (applied) {
@@ -483,17 +487,123 @@ export function createService({ store, mirror, config, onWrite, logger }) {
483
487
  * non-forgotten memories are mirrored: forgotten entries must not reach the
484
488
  * human-editable file (a human "edit" could otherwise resurrect them).
485
489
  */
490
+ // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
491
+ // 失败写 store.markMirrorDirty(dirty=1 + last_error + last_attempt),成功写
492
+ // store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
493
+ // try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
486
494
  function syncMirror() {
487
495
  if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
496
+ const now = new Date().toISOString();
488
497
  try {
489
498
  mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
499
+ // 成功:写 clean 状态
500
+ try {
501
+ store.markMirrorClean(now);
502
+ } catch (stateError) {
503
+ // markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
504
+ logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
505
+ }
490
506
  } catch (error) {
507
+ // 同步失败:写 dirty 状态
508
+ const errMsg = error?.message ?? String(error);
491
509
  logger?.warn?.("syncMirror failed:", error);
510
+ try {
511
+ store.markMirrorDirty(errMsg, now);
512
+ } catch (stateError) {
513
+ // markMirrorDirty 失败同样不能向外抛出
514
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
515
+ }
516
+ }
517
+ }
518
+
519
+ // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
520
+ // (F-NEW-03)。dirty=true → 有界重试(最多 3 次,立即重试)重跑 syncMirror
521
+ // 收敛镜像;某次成功(dirty 变 0)立即停止。返回 { recovered, error } 供
522
+ // index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
523
+ function recoverMirror() {
524
+ const MAX_ATTEMPTS = 3;
525
+ let lastError = null;
526
+ let recovered = false;
527
+
528
+ try {
529
+ const state = store.getMirrorState();
530
+ if (!state?.dirty) {
531
+ // 本来就干净:无需恢复,视为成功
532
+ return { recovered: true, error: null };
533
+ }
534
+
535
+ // dirty 状态:最多尝试 3 次 sync
536
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
537
+ try {
538
+ syncMirror(); // syncMirror 内部已 catch,不会向外抛
539
+ const currentState = store.getMirrorState();
540
+ if (!currentState?.dirty) {
541
+ // 成功:镜像已收敛为 clean
542
+ recovered = true;
543
+ lastError = null;
544
+ break;
545
+ }
546
+ // 仍 dirty:记录最后一次错误供重试耗尽后上报
547
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
548
+ } catch (syncError) {
549
+ // syncMirror 理论不抛,fail-safe 兜底
550
+ const errMsg = syncError?.message ?? String(syncError);
551
+ logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
552
+ lastError = errMsg;
553
+ }
554
+ }
555
+
556
+ if (!recovered) {
557
+ logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
558
+ } else {
559
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
560
+ }
561
+ } catch (error) {
562
+ // fail-safe:任何意外异常不向外抛
563
+ lastError = error?.message ?? String(error);
564
+ logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
565
+ }
566
+
567
+ return { recovered, error: recovered ? null : lastError };
568
+ }
569
+
570
+ // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
571
+ // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
572
+ function getMirrorHealth() {
573
+ try {
574
+ const state = store.getMirrorState();
575
+ if (!state) {
576
+ // 无状态行:返回安全默认值
577
+ return {
578
+ dirty: false,
579
+ last_error: null,
580
+ last_attempt: null,
581
+ success_at: null
582
+ };
583
+ }
584
+ return {
585
+ dirty: Boolean(state.dirty),
586
+ last_error: state.last_error ?? null,
587
+ last_attempt: state.last_attempt ?? null,
588
+ success_at: state.success_at ?? null
589
+ };
590
+ } catch (error) {
591
+ // fail-safe:状态读取失败也不向外抛
592
+ logger?.warn?.("getMirrorHealth failed:", error);
593
+ return {
594
+ dirty: false,
595
+ last_error: error?.message ?? String(error),
596
+ last_attempt: null,
597
+ success_at: null
598
+ };
492
599
  }
493
600
  }
494
601
 
495
602
  return {
496
603
  saveWithDedupe,
604
+ recoverMirror,
605
+ getMirrorHealth,
606
+ getMirrorState: () => store.getMirrorState(),
497
607
  injectCandidates,
498
608
  mergeHumanEdits,
499
609
  toApiList,