@modusensus/dsh-mneme 0.2.4 → 0.2.5

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.
Files changed (45) hide show
  1. package/README.md +10 -9
  2. package/lib/config.js +5 -2
  3. package/lib/dream/decisions.js +174 -62
  4. package/lib/dream.js +30 -5
  5. package/lib/index.js +3 -1
  6. package/lib/mirror.js +7 -1
  7. package/lib/service.js +117 -3
  8. package/lib/store.js +40 -0
  9. package/lib/tools.js +47 -4
  10. package/package.json +3 -1
  11. package/scripts/benchmark-embed.js +201 -0
  12. package/scripts/benchmark-rerank.js +166 -0
  13. package/scripts/e2e-dsh.js +216 -0
  14. package/scripts/stress-dsh.js +255 -0
  15. package/scripts/sync-lib.js +47 -0
  16. package/src/config.js +5 -2
  17. package/src/dream/decisions.js +174 -62
  18. package/src/dream.js +30 -5
  19. package/src/index.js +3 -1
  20. package/src/mirror.js +7 -1
  21. package/src/service.js +117 -3
  22. package/src/store.js +40 -0
  23. package/src/tools.js +47 -4
  24. package/test/api.test.js +385 -0
  25. package/test/audit.test.js +290 -0
  26. package/test/client.test.js +26 -0
  27. package/test/clustering.test.js +100 -0
  28. package/test/commands.test.js +69 -0
  29. package/test/config.test.js +31 -0
  30. package/test/dream.test.js +526 -0
  31. package/test/helpers/dream-mock.js +82 -0
  32. package/test/inject.test.js +82 -0
  33. package/test/local-embedder.test.js +227 -0
  34. package/test/mirror.test.js +249 -0
  35. package/test/reflection.test.js +226 -0
  36. package/test/reranker.test.js +197 -0
  37. package/test/semantic.test.js +123 -0
  38. package/test/service-search.test.js +169 -0
  39. package/test/service.test.js +198 -0
  40. package/test/settings.test.js +101 -0
  41. package/test/store.test.js +293 -0
  42. package/test/stress.test.js +209 -0
  43. package/test/summarize.test.js +156 -0
  44. package/test/tools.test.js +265 -0
  45. package/test/vector-index.test.js +205 -0
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-236%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-258%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -19,16 +19,17 @@
19
19
  - **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
20
20
  - **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
21
21
 
22
- ### 模型工具(6 个)
22
+ ### 模型工具(7 个)
23
23
 
24
24
  | 工具 | 功能 |
25
25
  |------|------|
26
26
  | `memory_save` | 记录一条记忆(自动按标题去重合并) |
27
27
  | `memory_search` | 全文搜索(中文子串友好,可启用向量语义搜索) |
28
- | `memory_list` | 按类型分页列出 |
28
+ | `memory_list` | 按类型分页列出(`include_archived=true` 可查看已归档) |
29
29
  | `memory_update` | 修改已有记忆 |
30
30
  | `memory_delete` | 删除记忆 |
31
31
  | `memory_forget` | 抑制注入(降权不删除,可恢复) |
32
+ | `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
32
33
 
33
34
  ### 自动注入 + 会话摘要
34
35
 
@@ -173,8 +174,8 @@ dsh web
173
174
  | `vectorSearchThreshold` | `0.65` | 向量搜索相似度阈值 |
174
175
  | `hybridSearchVectorWeight` | `0.6` | 混合搜索向量权重 |
175
176
  | `hybridSearchKeywordWeight` | `0.4` | 混合搜索关键词权重 |
176
- | `rerankEnabled` | `true` | 是否启用 Rerank 精排 |
177
- | `rerankProvider` | `local` | Rerank 后端:`local` / `none` |
177
+ | `rerankEnabled` | `false` | 是否启用 Rerank 精排(显式开启才加载本地 onnxruntime 模型) |
178
+ | `rerankProvider` | `none` | Rerank 后端:`local` / `none`(默认 `none`) |
178
179
  | `rerankModel` | `Xenova/bge-reranker-base` | Rerank 交叉编码模型 |
179
180
  | `rerankBatchSize` | `8` | Rerank 批大小 |
180
181
  | `rerankMaxCandidates` | `30` | Rerank 最大候选数 |
@@ -196,7 +197,7 @@ dsh web
196
197
  │ 服务层:saveWithDedupe / injectCandidates │
197
198
  │ / mergeHumanEdits / onWrite 钩子 │
198
199
  ├─────────────────────────────────────────────────┤
199
- │ 模型接口:6 个工具 + 自动注入 + 会话摘要 │
200
+ │ 模型接口:7 个工具 + 自动注入 + 会话摘要 │
200
201
  ├─────────────────────────────────────────────────┤
201
202
  │ autoDream:阈值调度 → LLM 决策清单 │
202
203
  │ → 校验(fail-safe)→ 应用 → 摘要 │
@@ -213,7 +214,7 @@ src/
213
214
  ├── mirror.js # Markdown 镜像(渲染/解析,人工优先)
214
215
  ├── service.js # 领域逻辑(去重合并、注入筛选、写入钩子)
215
216
  ├── config.js # schemastery 配置 schema
216
- ├── tools.js # 6 个模型工具(defineTool)
217
+ ├── tools.js # 7 个模型工具(defineTool)
217
218
  ├── inject.js # systemPrompt.context 动态注入
218
219
  ├── summarize.js # 会话结束 LLM 摘要
219
220
  ├── dream.js # autoDream 调度 + runDream(LLM 决策 + 摘要)
@@ -224,7 +225,7 @@ src/
224
225
  lib/
225
226
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
226
227
  └── *.js # src 的同步分发产物
227
- test/ # 236 个 node:test 测试(含审计与三轴线压测不变量)
228
+ test/ # 258 个 node:test 测试(含审计与三轴线压测不变量)
228
229
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
229
230
  ```
230
231
 
@@ -233,7 +234,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
233
234
  ```bash
234
235
  cd dsh-mneme
235
236
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
236
- npm test # 运行 236 个测试
237
+ npm test # 运行 258 个测试
237
238
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
238
239
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
239
240
  ```
package/lib/config.js CHANGED
@@ -49,8 +49,11 @@ export const Config = z.object({
49
49
  hybridSearchKeywordWeight: z.number().min(0).max(1).default(0.4),
50
50
 
51
51
  // --- semantic: rerank layer (v0.2) --------------------------------------
52
- rerankEnabled: z.boolean().default(true),
53
- rerankProvider: z.union([z.const("local"), z.const("none")]).default("local"),
52
+ // Opt-in by default (item ⑥): the local cross-encoder pulls in onnxruntime
53
+ // (transformers.js) at init, so a bare install must not load it. Only an
54
+ // explicit rerankEnabled=true + rerankProvider="local" constructs LocalReranker.
55
+ rerankEnabled: z.boolean().default(false),
56
+ rerankProvider: z.union([z.const("local"), z.const("none")]).default("none"),
54
57
  rerankModel: z.string().default("Xenova/bge-reranker-base"),
55
58
  rerankBatchSize: z.natural().min(1).max(64).default(8),
56
59
  rerankMaxCandidates: z.natural().min(5).max(100).default(30),
@@ -102,79 +102,191 @@ export function validateDecisions(decisions, snapshot, options = {}) {
102
102
  return { ok: errors.length === 0, errors };
103
103
  }
104
104
 
105
+ /** Marker thrown when a decision target changed since the run snapshot. */
106
+ export class CasConflictError extends Error {
107
+ constructor(action, ids, reason) {
108
+ super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
109
+ this.name = "CasConflictError";
110
+ this.action = action;
111
+ this.ids = ids;
112
+ }
113
+ }
114
+
115
+ function decisionIds(d) {
116
+ return d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
117
+ }
118
+
119
+ /**
120
+ * CAS guard (item ①): every target memory must still match what the run
121
+ * snapshot captured — otherwise the decision was computed against stale state
122
+ * and applying it would overwrite a concurrent edit. Snapshotless replays skip
123
+ * the guard entirely (per-action idempotency checks handle those). Throws
124
+ * CasConflictError on the first mismatch; the caller's transaction rolls back.
125
+ */
126
+ function casGuard(service, snapshot, ids) {
127
+ if (!snapshot) return;
128
+ for (const id of ids) {
129
+ const expect = snapshot.get(id);
130
+ if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
131
+ const current = service.getById(id);
132
+ if (!current) {
133
+ throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
134
+ }
135
+ const changed = expect.updated_at !== undefined
136
+ ? current.updated_at !== expect.updated_at
137
+ : current.content !== expect.content || current.title !== expect.title;
138
+ if (changed) {
139
+ throw new CasConflictError(
140
+ "changed",
141
+ [id],
142
+ `memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
143
+ );
144
+ }
145
+ }
146
+ }
147
+
105
148
  /**
106
149
  * Apply a validated decision list to the service. Caller must validate first.
107
- *
108
- * Note: merge is intentionally non-atomic the keeper is updated before the
109
- * other sources are archived, so a failure between the two never loses content.
150
+ * Each decision runs inside its own SQLite transaction (item ②): the multi-step
151
+ * mutation of a decision is atomic, so a merge can never leave "keeper updated
152
+ * but source not archived" or vice versa a throwing sub-step rolls the whole
153
+ * decision back.
110
154
  *
111
155
  * @param decisions - validated decision list.
112
- * @param service - memory service (saveWithDedupe/getById/update/setArchived).
156
+ * @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
113
157
  * @param logger - optional logger ({ warn }); per-decision failures are logged.
114
- * @returns number of applied decisions (archive counts each archived memory as one).
158
+ * @param snapshot - optional Map<id, memory> captured before the LLM call; when
159
+ * provided, every decision target is CAS-checked against it and a decision
160
+ * computed from stale state is skipped and reported as a conflict instead of
161
+ * overwriting concurrent writes (item ①).
162
+ * @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
163
+ * applied - number of decisions/memories actually committed (archive counts
164
+ * each archived memory as one, merge/conflict/update count one).
165
+ * conflicts - decisions skipped because a target changed since the snapshot.
166
+ * failures - decisions that threw mid-transaction (fully rolled back).
167
+ * committed - the decisions that actually landed, for outcome/receipt based
168
+ * on real committed sub-steps rather than the raw LLM list.
115
169
  */
116
- export function applyDecisions(decisions, service, logger = null) {
170
+ export function applyDecisions(decisions, service, logger = null, snapshot = null) {
117
171
  let applied = 0;
172
+ const conflicts = [];
173
+ const failures = [];
174
+ const committed = [];
118
175
  for (const [i, d] of decisions.entries()) {
119
176
  try {
120
- if (d.action === "keep") continue;
121
- if (d.action === "archive") {
122
- for (const id of d.ids) {
123
- const mem = service.getById(id);
124
- if (mem && !mem.archived) { service.setArchived(id, true); applied++; }
125
- }
126
- } else if (d.action === "merge") {
127
- const keeper = service.getById(d.keepSource);
128
- if (!keeper || keeper.archived) continue;
129
- const sources = d.ids.filter((id) => id !== d.keepSource);
130
- // Idempotent replay: if every other source is already archived, this
131
- // merge already landed — skip so a replayed/concurrent decision never
132
- // double-counts or re-applies (guard against duplicate merges).
133
- if (sources.every((id) => service.getById(id)?.archived)) continue;
134
- service.update(d.keepSource, {
135
- title: d.title,
136
- content: d.content,
137
- importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
138
- });
139
- for (const id of sources) {
140
- const mem = service.getById(id);
141
- if (mem && !mem.archived) { service.setArchived(id, true); }
142
- }
143
- applied++;
144
- } else if (d.action === "conflict") {
145
- const winner = service.getById(d.winner);
146
- const loser = service.getById(d.loser);
147
- if (!winner || !loser) continue;
148
- // Idempotent replay: an already-archived loser means the conflict was
149
- // already adjudicated — skip so the provenance note is never
150
- // re-appended and the loser is not re-archived.
151
- if (loser.archived) continue;
152
- service.update(d.winner, {
153
- content: `${winner.content}\n\n(已否决旧信息:${[...loser.content].slice(0, 100).join("")})`
154
- });
155
- service.setArchived(d.loser, true);
156
- applied++;
157
- } else if (d.action === "update") {
158
- const id = d.ids[0];
159
- const mem = service.getById(id);
160
- if (!mem || mem.archived) continue;
161
- // 幂等检查:如果字段已与目标一致则跳过
162
- const same = (d.title === undefined || d.title === mem.title)
163
- && (d.content === undefined || d.content === mem.content)
164
- && (d.importance === undefined || d.importance === mem.importance);
165
- if (same) continue;
166
- service.update(id, {
167
- title: d.title ?? mem.title,
168
- content: d.content ?? mem.content,
169
- importance: d.importance ?? mem.importance
170
- });
171
- applied++;
177
+ // keep is a confirmed no-op: it commits nothing but still records the
178
+ // per-id disposition so the outcome covers every snapshot memory.
179
+ if (d.action === "keep") {
180
+ committed.push({ action: "keep", ids: d.ids });
181
+ continue;
172
182
  }
183
+ const outcome = applyOne(d, service, snapshot);
184
+ if (outcome === "skipped") continue;
185
+ applied += outcome.applied;
186
+ committed.push(outcome.committed);
173
187
  } catch (error) {
174
- // Skip individual bad decision; never corrupt the store. The optional
175
- // logger makes the failure visible instead of failing silently.
176
- logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
188
+ if (error instanceof CasConflictError) {
189
+ conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
190
+ logger?.warn?.(`dsh-mneme dream: ${error.message}`);
191
+ } else {
192
+ failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
193
+ logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
194
+ }
177
195
  }
178
196
  }
179
- return applied;
197
+ return { applied, conflicts, failures, committed };
198
+ }
199
+
200
+ function applyOne(d, service, snapshot) {
201
+ switch (d.action) {
202
+ case "archive": return applyArchive(d, service, snapshot);
203
+ case "merge": return applyMerge(d, service, snapshot);
204
+ case "conflict": return applyConflict(d, service, snapshot);
205
+ default: return applyUpdate(d, service, snapshot);
206
+ }
207
+ }
208
+
209
+ function applyArchive(d, service, snapshot) {
210
+ const targets = d.ids.filter((id) => {
211
+ const mem = service.getById(id);
212
+ return mem && !mem.archived; // existing, not-yet-archived rows only
213
+ });
214
+ if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
215
+ service.transaction(() => {
216
+ casGuard(service, snapshot, d.ids);
217
+ for (const id of d.ids) {
218
+ const mem = service.getById(id);
219
+ if (mem && !mem.archived) service.setArchived(id, true);
220
+ }
221
+ });
222
+ return { applied: targets.length, committed: { action: "archive", ids: targets } };
223
+ }
224
+
225
+ function applyMerge(d, service, snapshot) {
226
+ const sources = d.ids.filter((id) => id !== d.keepSource);
227
+ // Idempotent replay: if every other source is already archived, this merge
228
+ // already landed — skip so a replayed/concurrent decision never double-counts
229
+ // or re-applies (guard against duplicate merges).
230
+ if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
231
+ service.transaction(() => {
232
+ casGuard(service, snapshot, d.ids);
233
+ const keeper = service.getById(d.keepSource);
234
+ if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
235
+ service.update(d.keepSource, {
236
+ title: d.title,
237
+ content: d.content,
238
+ importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
239
+ });
240
+ for (const id of sources) {
241
+ const mem = service.getById(id);
242
+ if (mem && !mem.archived) service.setArchived(id, true);
243
+ }
244
+ });
245
+ return {
246
+ applied: 1,
247
+ committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance }
248
+ };
249
+ }
250
+
251
+ function applyConflict(d, service, snapshot) {
252
+ const winner = service.getById(d.winner);
253
+ const loser = service.getById(d.loser);
254
+ if (!winner || !loser) return "skipped";
255
+ // Idempotent replay: an already-archived loser means the conflict was already
256
+ // adjudicated — skip so the provenance note is never re-appended and the loser
257
+ // is not re-archived.
258
+ if (loser.archived) return "skipped";
259
+ service.transaction(() => {
260
+ casGuard(service, snapshot, [d.winner, d.loser]);
261
+ const winnerNow = service.getById(d.winner);
262
+ const loserNow = service.getById(d.loser);
263
+ if (!winnerNow || !loserNow || loserNow.archived) return;
264
+ service.update(d.winner, {
265
+ content: `${winnerNow.content}\n\n(已否决旧信息:${[...loserNow.content].slice(0, 100).join("")})`
266
+ });
267
+ service.setArchived(d.loser, true);
268
+ });
269
+ return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser } };
270
+ }
271
+
272
+ function applyUpdate(d, service, snapshot) {
273
+ const id = d.ids[0];
274
+ const mem = service.getById(id);
275
+ if (!mem || mem.archived) return "skipped";
276
+ // 幂等检查:如果字段已与目标一致则跳过
277
+ const same = (d.title === undefined || d.title === mem.title)
278
+ && (d.content === undefined || d.content === mem.content)
279
+ && (d.importance === undefined || d.importance === mem.importance);
280
+ if (same) return "skipped";
281
+ service.transaction(() => {
282
+ casGuard(service, snapshot, [id]);
283
+ const cur = service.getById(id);
284
+ if (!cur || cur.archived) return;
285
+ service.update(id, {
286
+ title: d.title ?? cur.title,
287
+ content: d.content ?? cur.content,
288
+ importance: d.importance ?? cur.importance
289
+ });
290
+ });
291
+ return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance } };
180
292
  }
package/lib/dream.js CHANGED
@@ -65,7 +65,10 @@ export function parseReceipt(receipt) {
65
65
  const parts = receipt.split(":");
66
66
  if (parts.length !== 8 || parts[0] !== "dsh-mneme" || parts[1] !== "run") return undefined;
67
67
  const [, , runId, status, snapshotHash, inputCount, applied, summaryStored] = parts;
68
- if (!runId || !/^(ok|failed)$/.test(status)) return undefined;
68
+ // reconcile = decisions validated but one or more did not commit (CAS
69
+ // conflict / transaction rollback) — the store diverges from the decision
70
+ // list and the run must be reconciled, never reported as a fake ok.
71
+ if (!runId || !/^(ok|failed|reconcile)$/.test(status)) return undefined;
69
72
  const count = Number(inputCount);
70
73
  const appliedN = Number(applied);
71
74
  if (!Number.isInteger(count) || !Number.isInteger(appliedN)) return undefined;
@@ -282,7 +285,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
282
285
  // (e.g. summary step failed after consolidation), so the partial write is
283
286
  // replayable too.
284
287
  const finish = (result) => {
285
- const status = result.ok ? "ok" : "failed";
288
+ // status is derived from what actually committed: ok only when the full
289
+ // decision list landed; reconcile when decisions were validated but some
290
+ // did not commit (CAS conflict / rollback); failed on any LLM/validation
291
+ // error. No fake "ok" for a partial commit.
292
+ const status = result.status ?? (result.ok ? "ok" : "failed");
286
293
  const applied = result.applied ?? 0;
287
294
  const summaryStored = result.summary ?? false;
288
295
  const receipt = buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.size, applied, summaryStored });
@@ -407,7 +414,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
407
414
  }
408
415
  }
409
416
 
410
- const applied = applyDecisions(decisions, service, logger);
417
+ // CAS-guarded, per-decision-transactional apply against the run snapshot:
418
+ // a target changed during the LLM call is skipped and reported as a
419
+ // conflict instead of being overwritten (item ①).
420
+ const { applied, conflicts, failures, committed } = applyDecisions(decisions, service, logger, snapshot);
411
421
  // Attach the pre-update snapshot to the audit copy of each update decision
412
422
  // so the recorded row shows the before/after delta, not just the target.
413
423
  const auditDecisions = decisions.map((d) =>
@@ -415,7 +425,13 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
415
425
  ? { ...d, _before: updateSnapshots[d.ids[0]] }
416
426
  : d
417
427
  );
418
- const outcome = buildOutcome(decisions);
428
+ // Outcome is derived from the ACTUALLY committed sub-steps, never from the
429
+ // raw LLM decision list — a merge whose archive step rolled back must not
430
+ // claim "merge-archived" (item ②). Conflicts/failures ride along so the
431
+ // audit row records why the run diverged.
432
+ const outcome = { ...buildOutcome(committed), conflicts, failures };
433
+ // Decisions validated but not fully committed → reconcile (not ok).
434
+ const partial = conflicts.length > 0 || failures.length > 0;
419
435
 
420
436
  // Keep the vector index consistent with the post-dream store state.
421
437
  if (semantic?.embedder && semantic?.vectorIndex) {
@@ -460,7 +476,16 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
460
476
  } catch { /* best-effort */ }
461
477
  }
462
478
  }
463
- return finish({ ok: true, applied, decisions: auditDecisions, outcome, summary: summaryStored });
479
+ return finish({
480
+ ok: !partial,
481
+ status: partial ? "reconcile" : "ok",
482
+ applied,
483
+ decisions: auditDecisions,
484
+ outcome,
485
+ conflicts,
486
+ failures,
487
+ summary: summaryStored
488
+ });
464
489
  }
465
490
 
466
491
  return { maybeSchedule, runDream, dispose };
package/lib/index.js CHANGED
@@ -83,7 +83,9 @@ export const apply = (ctx, config) => {
83
83
  }
84
84
 
85
85
  // Cross-encoder rerank over recall candidates. Best-effort: a failed model
86
- // load only disables reranking, never search itself.
86
+ // load only disables reranking, never search itself. Explicit opt-in only
87
+ // (rerankEnabled defaults to false): constructing LocalReranker is what pulls
88
+ // in onnxruntime, so the default config never loads it (item ⑥).
87
89
  if (cfg.rerankEnabled && cfg.rerankProvider === "local") {
88
90
  try {
89
91
  reranker = new LocalReranker({
package/lib/mirror.js CHANGED
@@ -92,10 +92,16 @@ export function createMirror(dir) {
92
92
  if (lastSep) body = body.slice(0, lastSep.index);
93
93
  body = body.trim();
94
94
 
95
+ // The machine-written "更新时间" line records the store's updated_at at
96
+ // render time — the version token for detecting a concurrent store write
97
+ // during a three-way merge of human edits (see service.syncMirror).
98
+ const block = text.slice(blockStart, blockEnd);
99
+ const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
95
100
  edits.push({
96
101
  id: anchor[1],
97
102
  title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
98
- content: body
103
+ content: body,
104
+ updated_at: updatedMatch ? updatedMatch[1].trim() : undefined
99
105
  });
100
106
 
101
107
  const lineEnd = text.indexOf("\n", blockStart);
package/lib/service.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { TYPE_FILE } from "./mirror.js";
2
3
 
3
4
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
4
5
 
@@ -15,7 +16,14 @@ export function createService({ store, mirror, config, onWrite }) {
15
16
  let vectorIndex = null;
16
17
  let reranker = null;
17
18
 
19
+ // Transaction nesting depth. Inside service.transaction the per-mutation side
20
+ // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
21
+ // never leaves the mirror file diverged from the database; transaction()
22
+ // replays them exactly once against the committed state.
23
+ let txDepth = 0;
24
+
18
25
  function scheduleEmbed(memory) {
26
+ if (txDepth > 0) return; // deferred to the transaction's commit
19
27
  if (embedder && memory?.id) {
20
28
  try { embedder.schedule(memory); } catch { /* ignore */ }
21
29
  }
@@ -152,6 +160,7 @@ export function createService({ store, mirror, config, onWrite }) {
152
160
  * state toggles, not content writes, so they never notify.
153
161
  */
154
162
  function notifyWrite() {
163
+ if (txDepth > 0) return; // deferred to the transaction's commit
155
164
  if (onWrite) {
156
165
  try { onWrite(); } catch { /* ignore */ }
157
166
  }
@@ -160,6 +169,31 @@ export function createService({ store, mirror, config, onWrite }) {
160
169
  }
161
170
  }
162
171
 
172
+ /**
173
+ * Run several store mutations atomically (SQLite BEGIN/COMMIT/ROLLBACK) and
174
+ * fire the deferred side effects once against the committed state. A throwing
175
+ * body rolls the whole batch back — no partial writes, no diverged mirror.
176
+ * Errors propagate to the caller. NOTE: the commit path re-renders the mirror
177
+ * and notifies subscribers, but re-embedding is left to the caller (the dream
178
+ * flow re-embeds through maintainIndexAfterDream).
179
+ */
180
+ function transaction(fn) {
181
+ store.db.exec("BEGIN");
182
+ txDepth++;
183
+ try {
184
+ const result = fn();
185
+ store.db.exec("COMMIT");
186
+ return result;
187
+ } catch (error) {
188
+ try { store.db.exec("ROLLBACK"); } catch { /* store may be closed */ }
189
+ throw error;
190
+ } finally {
191
+ txDepth--;
192
+ syncMirror();
193
+ notifyWrite();
194
+ }
195
+ }
196
+
163
197
  /**
164
198
  * Save a memory, merging into an existing one when title matches within the same type.
165
199
  * @returns {{action: "created"|"merged", memory: object}}
@@ -253,12 +287,62 @@ export function createService({ store, mirror, config, onWrite }) {
253
287
  }
254
288
 
255
289
  /**
256
- * Re-render the human-editable mirror after any store mutation. Only
290
+ * Three-way merge of in-flight human mirror edits before a re-render.
291
+ * Runs on every syncMirror, so a human edit made between two store writes is
292
+ * never silently overwritten by the next sync (human priority is not limited
293
+ * to startup). Per edited entry:
294
+ * - file changed only → human wins; the edit is merged back into the store.
295
+ * - file AND store changed → real three-way conflict: keep the human edit
296
+ * and append a marker preserving the store's concurrent version, so no
297
+ * side is dropped.
298
+ * - store changed only → store wins (the file is simply re-rendered).
299
+ * Only title/content are taken (structure fields stay machine-owned, matching
300
+ * mergeHumanEdits). Returns the memory list to render.
301
+ */
302
+ function reconcileHumanEdits(memories) {
303
+ if (!mirror) return memories;
304
+ const byType = new Map();
305
+ for (const m of memories) {
306
+ if (!byType.has(m.type)) byType.set(m.type, []);
307
+ byType.get(m.type).push(m);
308
+ }
309
+ const result = [];
310
+ for (const type of Object.keys(TYPE_FILE)) {
311
+ const list = byType.get(type) ?? [];
312
+ if (list.length === 0) continue;
313
+ const editsById = new Map(mirror.readHumanEdits(type).map((e) => [e.id, e]));
314
+ for (const m of list) {
315
+ const edit = editsById.get(m.id);
316
+ if (!edit) { result.push(m); continue; }
317
+ const humanChanged = (typeof edit.title === "string" && edit.title !== m.title)
318
+ || (typeof edit.content === "string" && edit.content !== m.content);
319
+ if (!humanChanged) { result.push(m); continue; }
320
+ // Store changed since the file was last rendered (file records the
321
+ // store's updated_at at render time) AND the file was hand-edited.
322
+ const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
323
+ if (storeChanged) {
324
+ const marker = `\n\n> ⚠️ 并发冲突:人工编辑 vs 记忆库并发更新(${m.updated_at})\n> 记忆库版本:${m.content}`;
325
+ store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
326
+ } else {
327
+ store.update(m.id, { title: edit.title, content: edit.content });
328
+ }
329
+ const merged = store.getById(m.id);
330
+ scheduleEmbed(merged);
331
+ result.push(merged);
332
+ }
333
+ }
334
+ return result;
335
+ }
336
+
337
+ /**
338
+ * Re-render the human-editable mirror after any store mutation, merging any
339
+ * in-flight human edits first (never silently overwriting them). Only
257
340
  * non-forgotten memories are mirrored: forgotten entries must not reach the
258
341
  * human-editable file (a human "edit" could otherwise resurrect them).
259
342
  */
260
343
  function syncMirror() {
261
- if (mirror) mirror.sync(store.list({ limit: 500, includeForgotten: false }));
344
+ if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
345
+ mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
262
346
  }
263
347
 
264
348
  return {
@@ -266,6 +350,7 @@ export function createService({ store, mirror, config, onWrite }) {
266
350
  injectCandidates,
267
351
  mergeHumanEdits,
268
352
  toApiList,
353
+ transaction,
269
354
  setDreamHook(fn) { dreamHook = fn; },
270
355
  setEmbedder(emb) { embedder = emb; },
271
356
  setVectorIndex(vi) { vectorIndex = vi; },
@@ -277,7 +362,7 @@ export function createService({ store, mirror, config, onWrite }) {
277
362
  embeddedCount: () => store.embeddedCount(),
278
363
  list: (o) => store.list(o),
279
364
  all: () => store.all(),
280
- count: (type) => store.count(type),
365
+ count: (type, opts) => store.count(type, opts),
281
366
  getById: (id) => store.getById(id),
282
367
  remove: (id) => {
283
368
  store.remove(id);
@@ -312,6 +397,35 @@ export function createService({ store, mirror, config, onWrite }) {
312
397
  scheduleEmbed(updated);
313
398
  return updated;
314
399
  },
400
+ // Compare-and-set update: applies the patch only when the row still carries
401
+ // `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
402
+ // can re-read and retry — the primitive that prevents lost updates across
403
+ // concurrent read-modify-write (see scripts/stress-dsh.js axis 3).
404
+ compareAndUpdate: (id, expectedUpdatedAt, patch, ctx = {}) => {
405
+ const old = store.getById(id);
406
+ const updated = store.compareAndUpdate(id, expectedUpdatedAt, patch);
407
+ if (updated === undefined) return undefined; // CAS miss: no write, no side effects
408
+ const hasMeaningfulChange = old && updated && (
409
+ old.content !== updated.content ||
410
+ old.title !== updated.title ||
411
+ old.importance !== updated.importance
412
+ );
413
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
414
+ store.saveFailure({
415
+ id: randomUUID(),
416
+ query: ctx.query ?? null,
417
+ expected: updated.content,
418
+ actual: old.content,
419
+ before: { title: old.title, content: old.content, importance: old.importance },
420
+ failure_type: "user_correction",
421
+ memory_id: id
422
+ });
423
+ }
424
+ syncMirror();
425
+ notifyWrite();
426
+ scheduleEmbed(updated);
427
+ return updated;
428
+ },
315
429
  setForget: (id, f) => {
316
430
  const updated = store.setForget(id, f);
317
431
  syncMirror();