@modusensus/dsh-mneme 0.3.2 → 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/config.js +3 -1
- package/lib/local-embedder.js +6 -1
- package/lib/reranker.js +6 -1
- package/lib/service.js +5 -1
- package/package.json +1 -1
- package/src/config.js +3 -1
- package/src/local-embedder.js +6 -1
- package/src/reranker.js +6 -1
- package/src/service.js +5 -1
- package/test/mirror-dirty.test.js +48 -19
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/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/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) {
|
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/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/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) {
|
|
@@ -17,8 +17,9 @@ import { createSettings } from "../src/settings.js";
|
|
|
17
17
|
* - store.js 新增 mirror_state 表 + set/getMirrorState + markMirrorDirty/Clean
|
|
18
18
|
* - service.syncMirror 失败写 dirty(dirty=1+last_error+last_attempt)、成功写
|
|
19
19
|
* clean(dirty=0+last_error=null+success_at);状态写入各自 try/catch 不向外抛
|
|
20
|
-
* - service.recoverMirror() 启动时 dirty
|
|
21
|
-
*
|
|
20
|
+
* - service.recoverMirror() 启动时 dirty 则做有界重试(最多 3 次)收敛镜像,
|
|
21
|
+
* 仍失败保持 dirty 下次再试;返回 { recovered, error },绝不向外抛
|
|
22
|
+
* - service.getMirrorHealth() 供 api.js /api/dsh-mneme/health 使用
|
|
22
23
|
* - index.js 启动时调 recoverMirror()
|
|
23
24
|
*
|
|
24
25
|
* 测试点由 Kimi K2.7 设计(TC-F-03-001~011)。
|
|
@@ -134,20 +135,22 @@ test("F-NEW-03: syncMirror 成功 → dirty=0+last_error=null+success_at,镜
|
|
|
134
135
|
}
|
|
135
136
|
});
|
|
136
137
|
|
|
137
|
-
// ── TC-F-03-006:recoverMirror dirty+成功 → 恢复 clean
|
|
138
|
+
// ── TC-F-03-006:recoverMirror dirty+成功 → 恢复 clean,首试即中恰好一次 ────
|
|
138
139
|
|
|
139
|
-
test("F-NEW-03: recoverMirror dirty→成功恢复 clean
|
|
140
|
+
test("F-NEW-03: recoverMirror dirty→成功恢复 clean,首试即中、镜像收敛、返回 recovered", () => {
|
|
140
141
|
const { dir, mirrorDir, store, mirror, service } = setup();
|
|
141
142
|
try {
|
|
142
143
|
// 制造 dirty(失败一次)
|
|
143
144
|
const { original } = throwingMirrorSync(mirror);
|
|
144
145
|
service.saveWithDedupe({ type: "project", title: "启动恢复", content: "Dirty 数据", importance: 3 });
|
|
145
146
|
assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
|
|
146
|
-
// 恢复后包计数,recoverMirror
|
|
147
|
+
// 恢复后包计数,recoverMirror 首次尝试即成功 → sync 恰好一次
|
|
147
148
|
mirror.sync = original;
|
|
148
149
|
const counter = countingSync(mirror);
|
|
149
|
-
|
|
150
|
-
assert.equal(
|
|
150
|
+
const result = service.recoverMirror();
|
|
151
|
+
assert.equal(result.recovered, true, "恢复成功必须返回 recovered=true");
|
|
152
|
+
assert.equal(result.error, null);
|
|
153
|
+
assert.equal(counter.calls(), 1, "首试即中:sync 必须只调一次");
|
|
151
154
|
const state = service.getMirrorState();
|
|
152
155
|
assert.equal(state.dirty, false, "恢复成功必须清 dirty");
|
|
153
156
|
assert.equal(state.last_error, null);
|
|
@@ -160,26 +163,49 @@ test("F-NEW-03: recoverMirror dirty→成功恢复 clean,sync 恰好一次,
|
|
|
160
163
|
}
|
|
161
164
|
});
|
|
162
165
|
|
|
163
|
-
// ── TC-F-03-007:recoverMirror 失败保持 dirty
|
|
166
|
+
// ── TC-F-03-007:recoverMirror 失败保持 dirty,有界重试后放弃,不抛 ─────────
|
|
164
167
|
|
|
165
|
-
test("F-NEW-03: recoverMirror 仍失败 → dirty
|
|
168
|
+
test("F-NEW-03: recoverMirror 仍失败 → dirty 保持、返回 recovered=false+error,不抛", () => {
|
|
166
169
|
const { dir, store, mirror, service } = setup();
|
|
167
170
|
try {
|
|
168
|
-
const { original
|
|
171
|
+
const { original } = throwingMirrorSync(mirror);
|
|
169
172
|
try {
|
|
170
173
|
service.saveWithDedupe({ type: "project", title: "再失败", content: "x", importance: 3 });
|
|
171
174
|
const dirtyState = service.getMirrorState();
|
|
172
175
|
assert.equal(dirtyState.dirty, true, "前置:必须 dirty");
|
|
173
|
-
const counter = countingSync(mirror); //
|
|
174
|
-
// countingSync 换掉了 mock;需要保留抛错行为 —— 用原 mock 再次包计数
|
|
175
|
-
mirror.sync = () => { counter.calls(); throw new Error("still broken"); };
|
|
176
|
+
const counter = countingSync(mirror); // 包装的仍是抛错 mock → 每次尝试都失败
|
|
176
177
|
assert.doesNotThrow(() => service.recoverMirror(), "recoverMirror 失败也不得抛");
|
|
177
178
|
const after = service.getMirrorState();
|
|
178
179
|
assert.equal(after.dirty, true, "失败后 dirty 必须保持,供下次启动再试");
|
|
179
|
-
assert.
|
|
180
|
+
assert.ok(after.last_error, "last_error 须有值");
|
|
180
181
|
assert.ok(after.last_attempt >= dirtyState.last_attempt, "last_attempt 须更新");
|
|
181
182
|
assert.equal(after.success_at, null);
|
|
182
|
-
|
|
183
|
+
// 有界性:失败路径最多重试 MAX_ATTEMPTS(3) 次后放弃,不得无限循环
|
|
184
|
+
assert.ok(counter.calls() <= 3, `recover 失败必须是有界重试,实际 ${counter.calls()} 次`);
|
|
185
|
+
} finally {
|
|
186
|
+
mirror.sync = original;
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
store.close();
|
|
190
|
+
rmSync(dir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── 有界性(Kimi 风险点 3):recoverMirror 重试次数精确 ≤3 ──────────────────
|
|
195
|
+
|
|
196
|
+
test("F-NEW-03: recoverMirror 失败路径恰好重试 3 次后放弃(有界不无限)", () => {
|
|
197
|
+
const { dir, store, mirror, service } = setup();
|
|
198
|
+
try {
|
|
199
|
+
const { original } = throwingMirrorSync(mirror);
|
|
200
|
+
try {
|
|
201
|
+
service.saveWithDedupe({ type: "project", title: "有界", content: "x", importance: 3 });
|
|
202
|
+
assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
|
|
203
|
+
const counter = countingSync(mirror);
|
|
204
|
+
const result = service.recoverMirror();
|
|
205
|
+
assert.equal(counter.calls(), 3, "失败路径必须恰好尝试 3 次(MAX_ATTEMPTS)");
|
|
206
|
+
assert.equal(result.recovered, false, "3 次耗尽仍失败 → recovered=false");
|
|
207
|
+
assert.ok(result.error, "必须携带失败原因 error");
|
|
208
|
+
assert.equal(service.getMirrorState().dirty, true, "放弃后 dirty 保持,下次启动再试");
|
|
183
209
|
} finally {
|
|
184
210
|
mirror.sync = original;
|
|
185
211
|
}
|
|
@@ -191,18 +217,21 @@ test("F-NEW-03: recoverMirror 仍失败 → dirty 保持 1、last_error/attempt
|
|
|
191
217
|
|
|
192
218
|
// ── TC-F-03-008:recoverMirror 不 dirty 时 no-op ────────────────────────────
|
|
193
219
|
|
|
194
|
-
test("F-NEW-03: recoverMirror 不 dirty →
|
|
220
|
+
test("F-NEW-03: recoverMirror 不 dirty → 不重渲染、状态不变、返回 recovered=true", () => {
|
|
195
221
|
const { dir, store, mirror, service } = setup();
|
|
196
222
|
try {
|
|
197
223
|
const counter = countingSync(mirror);
|
|
198
|
-
|
|
224
|
+
const result = service.recoverMirror();
|
|
199
225
|
assert.equal(counter.calls(), 0, "不 dirty 时必须 no-op,不调 sync");
|
|
226
|
+
assert.equal(result.recovered, true, "本来干净 → recovered=true 且不调 sync");
|
|
200
227
|
const state = service.getMirrorState();
|
|
201
228
|
assert.equal(state.dirty, false, "状态不得改变");
|
|
202
|
-
// 已有数据但 dirty=0 同样 no-op
|
|
229
|
+
// 已有数据但 dirty=0 同样 no-op(saveWithDedupe 自身会同步一次,记录基线)
|
|
203
230
|
service.saveWithDedupe({ type: "project", title: "干净数据", content: "x", importance: 3 });
|
|
204
231
|
assert.equal(service.getMirrorState().dirty, false);
|
|
205
|
-
|
|
232
|
+
const before = counter.calls();
|
|
233
|
+
service.recoverMirror();
|
|
234
|
+
assert.equal(counter.calls(), before, "干净状态下 recoverMirror 不得触发同步");
|
|
206
235
|
} finally {
|
|
207
236
|
store.close();
|
|
208
237
|
rmSync(dir, { recursive: true, force: true });
|