@modusensus/dsh-mneme 0.3.2 → 0.3.4

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/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
 
@@ -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) {
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.2",
4
+ "version": "0.3.4",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -60,5 +60,8 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@huggingface/transformers": "^4.2.0"
63
+ },
64
+ "overrides": {
65
+ "adm-zip": "0.6.0"
63
66
  }
64
67
  }
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
 
@@ -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) {
@@ -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 则尝试一次同步,失败保持 dirty 下次再试
21
- * - api.js /api/dsh-mneme/health 返回 mirror 状态
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,sync 恰好一次,镜像收敛", () => {
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
- assert.doesNotThrow(() => service.recoverMirror(), "recoverMirror 不得抛");
150
- assert.equal(counter.calls(), 1, "recover 必须只尝试一次(有界)");
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 保持 1、last_error/attempt 更新,不抛", () => {
168
+ test("F-NEW-03: recoverMirror 仍失败 → dirty 保持、返回 recovered=false+error,不抛", () => {
166
169
  const { dir, store, mirror, service } = setup();
167
170
  try {
168
- const { original, err } = throwingMirrorSync(mirror);
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.equal(after.last_error, "still broken", "last_error 须更新为新错误");
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
- assert.ok(counter.calls() >= 1, "recover 只应尝试一次");
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
- assert.doesNotThrow(() => service.recoverMirror(), "空表 recoverMirror 不得抛");
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
- assert.equal(counter.calls(), 0, "干净状态下 recoverMirror 不得触发同步");
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 });