@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
@@ -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/src/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/src/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/src/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/src/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();
package/src/store.js CHANGED
@@ -223,6 +223,45 @@ export function createStore(path) {
223
223
  db.prepare("DELETE FROM memories WHERE id = ?").run(id);
224
224
  }
225
225
 
226
+ /**
227
+ * Atomic compare-and-set update: applies `patch` only when the row still
228
+ * carries `expectedUpdatedAt` (the version token read by the caller). Returns
229
+ * the updated memory on success, or undefined when the row changed since the
230
+ * caller read it — the caller must re-read and retry. The version guard lives
231
+ * in the UPDATE's WHERE clause, so a concurrent read-modify-write across
232
+ * connections cannot silently overwrite a newer value (lost update).
233
+ */
234
+ function compareAndUpdate(id, expectedUpdatedAt, patch) {
235
+ const existing = getById(id);
236
+ if (!existing) throw new Error(`memory not found: ${id}`);
237
+ const type = patch.type ?? existing.type;
238
+ if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
239
+ if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
240
+ throw new Error("tags must be an array");
241
+ }
242
+ const now = nowIso();
243
+ const embedding = patch.embedding !== undefined
244
+ ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
245
+ : existing.embedding ?? null;
246
+ const result = db.prepare(
247
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
248
+ WHERE id=? AND updated_at=?`
249
+ ).run(
250
+ type,
251
+ patch.title ?? existing.title,
252
+ patch.content ?? existing.content,
253
+ JSON.stringify(patch.tags ?? existing.tags),
254
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
255
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
256
+ embedding,
257
+ now,
258
+ id,
259
+ expectedUpdatedAt
260
+ );
261
+ if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
262
+ return getById(id);
263
+ }
264
+
226
265
  function setForget(id, forgotten) {
227
266
  db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
228
267
  .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
@@ -457,6 +496,7 @@ export function createStore(path) {
457
496
  getById,
458
497
  save,
459
498
  update,
499
+ compareAndUpdate,
460
500
  remove,
461
501
  setForget,
462
502
  setArchived,
package/src/tools.js CHANGED
@@ -98,11 +98,12 @@ export function createTools(ctx, service, config, embedder) {
98
98
 
99
99
  defineTool({
100
100
  name: "memory_list",
101
- description: "List memory entries by type, high-importance first, then newest, paginated.",
101
+ description: "List memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored.",
102
102
  parameters: {
103
103
  type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
104
104
  limit: { type: "integer", description: "Page size (default 50)" },
105
- offset: { type: "integer", description: "Page offset (default 0)" }
105
+ offset: { type: "integer", description: "Page offset (default 0)" },
106
+ include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
106
107
  },
107
108
  output: {
108
109
  schema: {
@@ -119,8 +120,14 @@ export function createTools(ctx, service, config, embedder) {
119
120
  render: (_args, value) => TEXT_OUTPUT(`${value.items.length} memory entries (of ${value.total}).`)
120
121
  },
121
122
  async execute(args) {
122
- const rows = service.toApiList(service.list({ type: args.type, limit: args.limit ?? 50, offset: args.offset ?? 0 }));
123
- return { items: rows, total: service.count(args.type) };
123
+ const includeArchived = args.include_archived === true;
124
+ const rows = service.toApiList(service.list({
125
+ type: args.type,
126
+ limit: args.limit ?? 50,
127
+ offset: args.offset ?? 0,
128
+ includeArchived
129
+ }));
130
+ return { items: rows, total: service.count(args.type, { includeArchived }) };
124
131
  }
125
132
  }),
126
133
 
@@ -220,6 +227,42 @@ export function createTools(ctx, service, config, embedder) {
220
227
  const memory = service.setForget(args.id, args.forgotten ?? true);
221
228
  return { memory: { id: memory.id, forgotten: memory.forgotten } };
222
229
  }
230
+ }),
231
+
232
+ defineTool({
233
+ name: "memory_archive",
234
+ description:
235
+ "Archive a memory (hide it from active lists, search, injection and dream consolidation) or restore it. " +
236
+ "Archived entries stay in storage and are recoverable: pass archived=false to restore, and use memory_list with " +
237
+ "include_archived=true to find archived entries.",
238
+ parameters: {
239
+ id: { type: "string", required: true, description: "Memory id" },
240
+ archived: { type: "boolean", description: "Archive (true, default) or restore (false) the entry" }
241
+ },
242
+ output: {
243
+ schema: {
244
+ type: "object",
245
+ additionalProperties: false,
246
+ properties: {
247
+ memory: {
248
+ type: "object",
249
+ additionalProperties: false,
250
+ properties: {
251
+ id: { type: "string", required: true },
252
+ archived: { type: "boolean", required: true }
253
+ }
254
+ }
255
+ }
256
+ },
257
+ render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} ${value.memory.archived ? "archived" : "restored"}.`)
258
+ },
259
+ async execute(args) {
260
+ if (service.getById(args.id) === undefined) {
261
+ throw new Error("memory not found");
262
+ }
263
+ const memory = service.setArchived(args.id, args.archived ?? true);
264
+ return { memory: { id: memory.id, archived: memory.archived } };
265
+ }
223
266
  })
224
267
  ];
225
268