@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 +4 -4
- package/lib/api.js +18 -0
- package/lib/config.js +3 -1
- package/lib/index.js +6 -0
- package/lib/local-embedder.js +6 -1
- package/lib/reranker.js +6 -1
- package/lib/service.js +111 -1
- package/lib/store.js +93 -0
- package/package.json +1 -1
- package/src/api.js +18 -0
- package/src/config.js +3 -1
- package/src/index.js +6 -0
- package/src/local-embedder.js +6 -1
- package/src/reranker.js +6 -1
- package/src/service.js +111 -1
- package/src/store.js +93 -0
- package/test/fnew-03.test.js +415 -0
- package/test/mirror-dirty.test.js +420 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](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` | 空 | 模型缓存目录(空 =
|
|
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/ #
|
|
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 # 运行
|
|
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.
|
package/lib/local-embedder.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
package/src/local-embedder.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|