@modusensus/dsh-mneme 0.2.0 → 0.2.1

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/lib/api.js CHANGED
@@ -26,6 +26,13 @@ function parseBody(text) {
26
26
  export function createApi(ctx, service, settings, commands, embedder, semantic = null) {
27
27
  const disposers = [];
28
28
 
29
+ // Ensure the service has an embedder when the API layer was handed one
30
+ // (tests wire the embedder through the API instead of index.js). Without
31
+ // this, /api/dsh-mneme/search would silently degrade to keyword-only.
32
+ if (embedder && typeof service.setEmbedder === "function") {
33
+ service.setEmbedder(embedder);
34
+ }
35
+
29
36
  const register = (route) => {
30
37
  disposers.push(ctx.webServer.register(route));
31
38
  };
@@ -66,58 +73,24 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
66
73
  const limit = Number(url.searchParams.get("limit") ?? 20);
67
74
  // mode: auto (default) | keyword | vector | hybrid
68
75
  const mode = url.searchParams.get("mode") ?? "auto";
76
+ const rerank = url.searchParams.get("rerank") !== "false";
69
77
  const query = q.trim();
70
78
  if (!query) {
71
79
  sendJson(res, 200, { items: [], mode: "keyword" });
72
80
  return;
73
81
  }
74
- // Keyword results (existing behavior) always computed; used as a
75
- // fallback and as the primary ranking when vector is unavailable.
76
- const keyword = service.toApiList(service.search(query, { limit }));
77
- if (mode === "keyword" || !embedder) {
78
- sendJson(res, 200, { items: keyword, mode: "keyword" });
79
- return;
80
- }
81
- const cfg = settings.getVectorConfig();
82
- if (mode === "vector" && !cfg?.enabled) {
83
- sendJson(res, 200, { items: keyword, mode: "keyword", error: "vector-disabled" });
84
- return;
85
- }
86
- // Try vector search; on any failure fall back to keyword results.
87
- return embedder.embed(query).then(async (vector) => {
88
- let items = keyword;
89
- let used = "keyword";
90
- if (vector) {
91
- const scored = service.toApiList(service.searchVector(vector, { limit }));
92
- if (mode === "hybrid") {
93
- // hybrid: vector recalls lead, keyword fills remaining slots
94
- const seen = new Set(scored.map((m) => m.id));
95
- const merged = [...scored.slice(0, limit)];
96
- for (const m of keyword) {
97
- if (merged.length >= limit) break;
98
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
99
- }
100
- items = merged;
101
- used = "vector";
102
- } else {
103
- // auto/vector: keyword exact hits first (the user's literal
104
- // words), then vector results fill the remaining slots, deduped.
105
- const seen = new Set(keyword.map((m) => m.id));
106
- const merged = [...keyword];
107
- for (const m of scored) {
108
- if (merged.length >= limit) break;
109
- if (!seen.has(m.id)) {
110
- seen.add(m.id);
111
- merged.push(m);
112
- }
113
- }
114
- items = merged;
115
- used = "vector";
116
- }
117
- }
118
- sendJson(res, 200, { items, mode: used });
82
+ // Route through the unified semantic pipeline; any vector/rerank
83
+ // failure degrades to keyword results inside searchMemories. The
84
+ // returned promise lets the test double await the async search.
85
+ return Promise.resolve(
86
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
87
+ ).then((rows) => {
88
+ // mode reflects what actually happened: rows marked `vector` came
89
+ // through the semantic path, everything else is keyword fallback.
90
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
91
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
119
92
  }).catch(() => {
120
- sendJson(res, 200, { items: keyword, mode: "keyword" });
93
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
121
94
  });
122
95
  } catch {
123
96
  sendJson(res, 500, { error: "internal" });
@@ -202,7 +175,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
202
175
  }
203
176
  const url = new URL(req.url, "http://localhost");
204
177
  const limit = Number(url.searchParams.get("limit") ?? 100);
205
- embedder.reindexMissing(limit).then((result) => {
178
+ // Unified re-index entry: works for both the legacy OpenAI embedder and
179
+ // the new local/ollama backends (which have no reindexMissing method).
180
+ const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
181
+ const task = viaIndex
182
+ ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
183
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
184
+ task.then((result) => {
206
185
  sendJson(res, 200, result);
207
186
  }).catch(() => {
208
187
  sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
package/lib/config.js CHANGED
@@ -45,5 +45,11 @@ export const Config = z.object({
45
45
  rerankModel: z.string().default("Xenova/bge-reranker-base"),
46
46
  rerankBatchSize: z.natural().min(1).max(64).default(8),
47
47
  rerankMaxCandidates: z.natural().min(5).max(100).default(30),
48
- rerankScoreThreshold: z.number().min(0).max(1).default(0.1)
48
+ rerankScoreThreshold: z.number().min(0).max(1).default(0.1),
49
+
50
+ // --- reflection: update decision + failure tracking (v0.2.1) ------------
51
+ reflectionUpdateEnabled: z.boolean().default(true),
52
+ reflectionFailureTracking: z.boolean().default(true),
53
+ reflectionUpdateMaxPerRun: z.natural().min(0).max(5).default(2),
54
+ reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24)
49
55
  });
@@ -1,4 +1,4 @@
1
- const ACTIONS = new Set(["keep", "merge", "archive", "conflict"]);
1
+ const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
2
2
 
3
3
  /**
4
4
  * Validate a dream decision list against a snapshot of eligible memories.
@@ -6,8 +6,10 @@ const ACTIONS = new Set(["keep", "merge", "archive", "conflict"]);
6
6
  * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
7
7
  * @returns {{ok: boolean, errors: string[]}}
8
8
  */
9
- export function validateDecisions(decisions, snapshot) {
9
+ export function validateDecisions(decisions, snapshot, options = {}) {
10
10
  const errors = [];
11
+ const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
12
+ const minAgeHours = options.minAgeHours ?? 24;
11
13
  if (!Array.isArray(decisions) || decisions.length === 0) {
12
14
  return { ok: false, errors: ["decision list must be a non-empty array"] };
13
15
  }
@@ -57,6 +59,38 @@ export function validateDecisions(decisions, snapshot) {
57
59
  errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
58
60
  }
59
61
  }
62
+ if (d.action === "update") {
63
+ // 只能更新单条
64
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
65
+ errors.push(`${at}: update must target exactly one id`);
66
+ continue;
67
+ }
68
+ // 必须产生实际变化
69
+ const mem = snapshot.get(d.ids[0]);
70
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
71
+ || (d.content !== undefined && d.content !== mem?.content)
72
+ || (d.importance !== undefined && d.importance !== mem?.importance);
73
+ if (!hasChange) {
74
+ errors.push(`${at}: update must change at least one field`);
75
+ continue;
76
+ }
77
+ // 不能更新 summary
78
+ if (mem?.type === "summary") {
79
+ errors.push(`${at}: cannot update summary via update action`);
80
+ continue;
81
+ }
82
+ // 保护期:新建记忆不可立即被 update(可配置)
83
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
84
+ if (ageHours < minAgeHours) {
85
+ errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
86
+ continue;
87
+ }
88
+ }
89
+ }
90
+ // Cap update churn: too many edits in one cycle signals a runaway model
91
+ const updateCount = decisions.filter((d) => d.action === "update").length;
92
+ if (updateCount > maxUpdatePerRun) {
93
+ errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
60
94
  }
61
95
  // Every snapshot id must appear in at least one decision
62
96
  for (const id of snapshot.keys()) {
@@ -117,6 +151,21 @@ export function applyDecisions(decisions, service, logger = null) {
117
151
  });
118
152
  service.setArchived(d.loser, true);
119
153
  applied++;
154
+ } else if (d.action === "update") {
155
+ const id = d.ids[0];
156
+ const mem = service.getById(id);
157
+ if (!mem || mem.archived) continue;
158
+ // 幂等检查:如果字段已与目标一致则跳过
159
+ const same = (d.title === undefined || d.title === mem.title)
160
+ && (d.content === undefined || d.content === mem.content)
161
+ && (d.importance === undefined || d.importance === mem.importance);
162
+ if (same) continue;
163
+ service.update(id, {
164
+ title: d.title ?? mem.title,
165
+ content: d.content ?? mem.content,
166
+ importance: d.importance ?? mem.importance
167
+ });
168
+ applied++;
120
169
  }
121
170
  } catch (error) {
122
171
  // Skip individual bad decision; never corrupt the store. The optional
package/lib/dream.js CHANGED
@@ -10,7 +10,13 @@ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记
10
10
  1. 识别主题相近的条目 → 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
11
11
  2. 识别重复/过时信息 → 输出 archive
12
12
  3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser)
13
- 4. 无问题的条目 → 输出 keep
13
+ 4. 发现单条记忆中的信息已过时、错误或遗漏 → 输出 update(直接修正内容)
14
+ - update 的 ids 只能包含一个 id
15
+ - 必须提供修正后的 title 和/或 content
16
+ - 仅当内容确实需要修正时才使用,不要滥用
17
+ - 每次整理最多输出 2 个 update
18
+ - 24 小时内新建的记忆不可 update
19
+ 5. 无问题的条目 → 输出 keep
14
20
 
15
21
  规则:
16
22
  - 每条记忆至少出现在一个决策中
@@ -18,6 +24,7 @@ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记
18
24
  - 仅合并同类型条目(type 相同)
19
25
  - 不要编造 ids;只使用提供的 id
20
26
  - 重要性 1-5,合并后取最高
27
+ - update 只能改一条,且要有实际变化
21
28
  - 只输出 JSON 数组,不要其他文字`;
22
29
 
23
30
  function totalChars(memories) {
@@ -83,6 +90,8 @@ export function buildOutcome(decisions) {
83
90
  } else if (d.action === "conflict") {
84
91
  byId[d.winner] = "conflict-winner";
85
92
  byId[d.loser] = "conflict-archived";
93
+ } else if (d.action === "update") {
94
+ for (const id of d.ids) byId[id] = "updated";
86
95
  }
87
96
  }
88
97
  return { byId };
@@ -171,6 +180,17 @@ async function maintainIndexAfterDream(decisions, service, semantic) {
171
180
  }
172
181
  } else if (d.action === "archive" || d.action === "conflict") {
173
182
  for (const id of d.ids ?? [d.loser]) vectorIndex.deleteEmbedding(id);
183
+ } else if (d.action === "update") {
184
+ const id = d.ids[0];
185
+ const mem = service.getById(id);
186
+ if (mem) {
187
+ vectorIndex.deleteEmbedding(id);
188
+ try {
189
+ const text = [mem.title, mem.content].filter(Boolean).join("\n");
190
+ const v = await embedder.embedSingle(text);
191
+ if (v?.length) vectorIndex.saveEmbedding(id, v);
192
+ } catch { /* best-effort */ }
193
+ }
174
194
  }
175
195
  }
176
196
  for (const [id, text] of rebuild) {
@@ -369,13 +389,32 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
369
389
  logger?.warn?.("dsh-mneme dream: invalid decisions json");
370
390
  return finish({ ok: false, error: "invalid decisions json", summary: false });
371
391
  }
372
- const { ok, errors } = validateDecisions(decisions, snapshot);
392
+ const { ok, errors } = validateDecisions(decisions, snapshot, {
393
+ maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
394
+ minAgeHours: config.reflectionUpdateMinAgeHours
395
+ });
373
396
  if (!ok) {
374
397
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
375
398
  return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
376
399
  }
377
400
 
401
+ // Capture pre-update snapshots so the audit records what each update changed.
402
+ const updateSnapshots = {};
403
+ for (const d of decisions) {
404
+ if (d.action === "update") {
405
+ const mem = snapshot.get(d.ids[0]);
406
+ if (mem) updateSnapshots[d.ids[0]] = { title: mem.title, content: mem.content, importance: mem.importance };
407
+ }
408
+ }
409
+
378
410
  const applied = applyDecisions(decisions, service, logger);
411
+ // Attach the pre-update snapshot to the audit copy of each update decision
412
+ // so the recorded row shows the before/after delta, not just the target.
413
+ const auditDecisions = decisions.map((d) =>
414
+ d.action === "update" && updateSnapshots[d.ids[0]]
415
+ ? { ...d, _before: updateSnapshots[d.ids[0]] }
416
+ : d
417
+ );
379
418
  const outcome = buildOutcome(decisions);
380
419
 
381
420
  // Keep the vector index consistent with the post-dream store state.
@@ -403,7 +442,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
403
442
  });
404
443
  } catch (error) {
405
444
  logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
406
- return finish({ ok: false, error: "llm failed", applied, decisions, outcome, summary: false });
445
+ return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, summary: false });
407
446
  }
408
447
  let summaryStored = false;
409
448
  if (summaryText !== undefined && summaryText.trim()) {
@@ -421,7 +460,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
421
460
  } catch { /* best-effort */ }
422
461
  }
423
462
  }
424
- return finish({ ok: true, applied, decisions, outcome, summary: summaryStored });
463
+ return finish({ ok: true, applied, decisions: auditDecisions, outcome, summary: summaryStored });
425
464
  }
426
465
 
427
466
  return { maybeSchedule, runDream, dispose };
package/lib/reranker.js CHANGED
@@ -74,6 +74,7 @@ export class LocalReranker {
74
74
  this.pipeline = null;
75
75
  this._batchScorer = null;
76
76
  this._queryVec = null;
77
+ this._queryKey = null;
77
78
  }
78
79
 
79
80
  /** Load the model; throws when no strategy can be bound. */
@@ -140,10 +141,15 @@ export class LocalReranker {
140
141
  } else {
141
142
  // Feature extraction: mean-pool the concatenated pair and compare with
142
143
  // the query embedding via cosine. Degraded but model-agnostic.
144
+ // The query vector is cached per query string, so a new query always
145
+ // recomputes it instead of reusing a stale vector from the previous call.
146
+ this._queryVec = null;
147
+ this._queryKey = null;
143
148
  this._batchScorer = async (query, passages) => {
144
- if (!this._queryVec) {
149
+ if (this._queryKey !== query) {
145
150
  const t = await this.pipeline([query], { pooling: "mean", normalize: true });
146
151
  this._queryVec = tensorToRows(t)[0];
152
+ this._queryKey = query;
147
153
  }
148
154
  const t = await this.pipeline(passages.map((p) => `${query}\n${p}`), {
149
155
  pooling: "mean",
@@ -199,5 +205,6 @@ export class LocalReranker {
199
205
  }
200
206
  this.pipeline = null;
201
207
  this._queryVec = null;
208
+ this._queryKey = null;
202
209
  }
203
210
  }
package/lib/service.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
2
4
 
3
5
  export function createService({ store, mirror, config, onWrite }) {
@@ -51,12 +53,32 @@ export function createService({ store, mirror, config, onWrite }) {
51
53
  * useRerank runs the cross-encoder over the merged list when a reranker is
52
54
  * installed; results carry an extra `score` when reranked.
53
55
  */
56
+ // Weighted blend factor for hybrid search; exposed so callers can tune it.
57
+ const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
58
+
59
+ /**
60
+ * Give a keyword-hit row a relevance score in [0,1]: title hits score
61
+ * higher than content hits, then scaled by importance (1-5). This lets
62
+ * keyword results participate in weighted hybrid blends.
63
+ */
64
+ function scoreKeyword(row, q) {
65
+ const ql = q.toLowerCase();
66
+ const title = (row.title ?? "").toLowerCase();
67
+ const content = (row.content ?? "").toLowerCase();
68
+ const titleHit = title.includes(ql);
69
+ const base = titleHit ? 1 : content.includes(ql) ? 0.6 : 0.3;
70
+ return base * (0.5 + (row.importance ?? 3) / 10);
71
+ }
72
+
54
73
  async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
55
74
  const q = String(query ?? "").trim();
56
75
  if (!q) return [];
57
76
  const lim = topK > 0 ? topK : 20;
58
77
 
59
- const keyword = store.search(q, { limit: lim });
78
+ // Keyword results, decorated with a score so they can be weight-blended
79
+ // with vector results and reported uniformly.
80
+ const rawKeyword = store.search(q, { limit: lim });
81
+ const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q) }));
60
82
  const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
61
83
  let vector = [];
62
84
  if (wantVector && embedder) {
@@ -67,23 +89,44 @@ export function createService({ store, mirror, config, onWrite }) {
67
89
  : embedder.embed.bind(embedder);
68
90
  const qv = await embedSingle(q);
69
91
  if (qv?.length) {
70
- vector = vectorIndex
92
+ const hits = vectorIndex
71
93
  ? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
72
94
  : store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
95
+ vector = hits.map((m) => ({ ...m, vector: true }));
73
96
  }
74
97
  } catch { /* vector unavailable: keep keyword results */ }
75
98
  }
76
99
 
100
+ // Hybrid blending weights from config when provided.
101
+ const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
102
+ const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
103
+
77
104
  let merged;
78
105
  if (mode === "keyword") {
79
106
  merged = keyword;
80
107
  } else if (mode === "vector" || mode === "hybrid") {
81
- // semantic-first: vector recalls lead, keyword fills remaining slots
82
- merged = vector.length ? vector.slice(0, lim) : keyword;
83
- const seen = new Set(merged.map((m) => m.id));
108
+ // semantic-first: vector recalls lead, keyword fills remaining slots.
109
+ // Weighted blend when both sides scored the same memory; otherwise
110
+ // vector order leads (it is the semantic signal), keyword backfills.
111
+ const byId = new Map();
112
+ for (const m of vector) {
113
+ const rec = byId.get(m.id);
114
+ byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
115
+ }
84
116
  for (const m of keyword) {
85
- if (merged.length >= lim) break;
86
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
117
+ const rec = byId.get(m.id);
118
+ if (rec) {
119
+ // Same memory from both sides: blend the scores.
120
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
121
+ } else {
122
+ byId.set(m.id, m);
123
+ }
124
+ }
125
+ const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
126
+ merged = ranked.slice(0, lim);
127
+ if (merged.length < lim && !merged.length) {
128
+ // Vector unavailable entirely: fall back to plain keyword.
129
+ merged = keyword.slice(0, lim);
87
130
  }
88
131
  } else {
89
132
  // auto: keyword leads, vector fills remaining slots (legacy behavior)
@@ -242,7 +285,20 @@ export function createService({ store, mirror, config, onWrite }) {
242
285
  notifyWrite();
243
286
  },
244
287
  update: (id, p) => {
288
+ const old = store.getById(id);
245
289
  const updated = store.update(id, p);
290
+ // Record a user correction (only when content actually changed and the
291
+ // reflection failure tracker is enabled): expected = what it became,
292
+ // actual = what it was before. Feeds later reflection/evolution passes.
293
+ if (old && updated && config.reflectionFailureTracking && old.content !== updated.content) {
294
+ store.saveFailure({
295
+ id: randomUUID(),
296
+ expected: updated.content,
297
+ actual: old.content,
298
+ failure_type: "user_correction",
299
+ memory_id: id
300
+ });
301
+ }
246
302
  syncMirror();
247
303
  notifyWrite();
248
304
  scheduleEmbed(updated);
package/lib/store.js CHANGED
@@ -40,6 +40,21 @@ CREATE TABLE IF NOT EXISTS dream_runs (
40
40
  receipt TEXT NOT NULL
41
41
  );
42
42
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
43
+
44
+ -- failure_memories: records user corrections / reflection failures. Captures
45
+ -- what a memory was ("actual") vs what the user changed it to ("expected")
46
+ -- so later reflection passes can mine recurring correction patterns.
47
+ CREATE TABLE IF NOT EXISTS failure_memories (
48
+ id TEXT PRIMARY KEY,
49
+ query TEXT,
50
+ expected TEXT,
51
+ actual TEXT,
52
+ failure_type TEXT NOT NULL,
53
+ memory_id TEXT,
54
+ created_at TEXT NOT NULL
55
+ );
56
+ CREATE INDEX IF NOT EXISTS idx_failure_memories_created ON failure_memories(created_at);
57
+ CREATE INDEX IF NOT EXISTS idx_failure_memories_type ON failure_memories(failure_type);
43
58
  `;
44
59
 
45
60
  const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
@@ -383,6 +398,45 @@ export function createStore(path) {
383
398
  return rows.map(toDreamRun);
384
399
  }
385
400
 
401
+ // --- failure memories ----------------------------------------------------
402
+
403
+ /**
404
+ * Persist one failure record (user correction, failed expectation, etc.).
405
+ * Like the dream audit trail this is bookkeeping: it never triggers write
406
+ * hooks, so reflection mining of failures cannot loop back into the writer.
407
+ */
408
+ function saveFailure({ id, query, expected, actual, failure_type, memory_id }) {
409
+ const now = nowIso();
410
+ db.prepare(
411
+ `INSERT INTO failure_memories (id, query, expected, actual, failure_type, memory_id, created_at)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
413
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, failure_type, memory_id ?? null, now);
414
+ return { id, query, expected, actual, failure_type, memory_id, created_at: now };
415
+ }
416
+
417
+ function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
418
+ const clauses = [];
419
+ const params = [];
420
+ if (since) { clauses.push("created_at >= ?"); params.push(since); }
421
+ if (memory_id) { clauses.push("memory_id = ?"); params.push(memory_id); }
422
+ if (failure_type) { clauses.push("failure_type = ?"); params.push(failure_type); }
423
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
424
+ const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
425
+ const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
426
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off);
427
+ }
428
+
429
+ function getFailureStats({ since } = {}) {
430
+ const clause = since ? "WHERE created_at >= ?" : "";
431
+ const params = since ? [since] : [];
432
+ const rows = db.prepare(
433
+ `SELECT failure_type, count(*) AS c FROM failure_memories ${clause} GROUP BY failure_type`
434
+ ).all(...params);
435
+ const stats = {};
436
+ for (const row of rows) stats[row.failure_type] = row.c;
437
+ return stats;
438
+ }
439
+
386
440
  return {
387
441
  db,
388
442
  count,
@@ -402,6 +456,9 @@ export function createStore(path) {
402
456
  saveDreamRun,
403
457
  getDreamRun,
404
458
  listDreamRuns,
459
+ saveFailure,
460
+ listFailures,
461
+ getFailureStats,
405
462
  close() {
406
463
  db.close();
407
464
  }
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.2.0",
4
+ "version": "0.2.1",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -26,6 +26,13 @@ function parseBody(text) {
26
26
  export function createApi(ctx, service, settings, commands, embedder, semantic = null) {
27
27
  const disposers = [];
28
28
 
29
+ // Ensure the service has an embedder when the API layer was handed one
30
+ // (tests wire the embedder through the API instead of index.js). Without
31
+ // this, /api/dsh-mneme/search would silently degrade to keyword-only.
32
+ if (embedder && typeof service.setEmbedder === "function") {
33
+ service.setEmbedder(embedder);
34
+ }
35
+
29
36
  const register = (route) => {
30
37
  disposers.push(ctx.webServer.register(route));
31
38
  };
@@ -66,58 +73,24 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
66
73
  const limit = Number(url.searchParams.get("limit") ?? 20);
67
74
  // mode: auto (default) | keyword | vector | hybrid
68
75
  const mode = url.searchParams.get("mode") ?? "auto";
76
+ const rerank = url.searchParams.get("rerank") !== "false";
69
77
  const query = q.trim();
70
78
  if (!query) {
71
79
  sendJson(res, 200, { items: [], mode: "keyword" });
72
80
  return;
73
81
  }
74
- // Keyword results (existing behavior) always computed; used as a
75
- // fallback and as the primary ranking when vector is unavailable.
76
- const keyword = service.toApiList(service.search(query, { limit }));
77
- if (mode === "keyword" || !embedder) {
78
- sendJson(res, 200, { items: keyword, mode: "keyword" });
79
- return;
80
- }
81
- const cfg = settings.getVectorConfig();
82
- if (mode === "vector" && !cfg?.enabled) {
83
- sendJson(res, 200, { items: keyword, mode: "keyword", error: "vector-disabled" });
84
- return;
85
- }
86
- // Try vector search; on any failure fall back to keyword results.
87
- return embedder.embed(query).then(async (vector) => {
88
- let items = keyword;
89
- let used = "keyword";
90
- if (vector) {
91
- const scored = service.toApiList(service.searchVector(vector, { limit }));
92
- if (mode === "hybrid") {
93
- // hybrid: vector recalls lead, keyword fills remaining slots
94
- const seen = new Set(scored.map((m) => m.id));
95
- const merged = [...scored.slice(0, limit)];
96
- for (const m of keyword) {
97
- if (merged.length >= limit) break;
98
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
99
- }
100
- items = merged;
101
- used = "vector";
102
- } else {
103
- // auto/vector: keyword exact hits first (the user's literal
104
- // words), then vector results fill the remaining slots, deduped.
105
- const seen = new Set(keyword.map((m) => m.id));
106
- const merged = [...keyword];
107
- for (const m of scored) {
108
- if (merged.length >= limit) break;
109
- if (!seen.has(m.id)) {
110
- seen.add(m.id);
111
- merged.push(m);
112
- }
113
- }
114
- items = merged;
115
- used = "vector";
116
- }
117
- }
118
- sendJson(res, 200, { items, mode: used });
82
+ // Route through the unified semantic pipeline; any vector/rerank
83
+ // failure degrades to keyword results inside searchMemories. The
84
+ // returned promise lets the test double await the async search.
85
+ return Promise.resolve(
86
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
87
+ ).then((rows) => {
88
+ // mode reflects what actually happened: rows marked `vector` came
89
+ // through the semantic path, everything else is keyword fallback.
90
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
91
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
119
92
  }).catch(() => {
120
- sendJson(res, 200, { items: keyword, mode: "keyword" });
93
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
121
94
  });
122
95
  } catch {
123
96
  sendJson(res, 500, { error: "internal" });
@@ -202,7 +175,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
202
175
  }
203
176
  const url = new URL(req.url, "http://localhost");
204
177
  const limit = Number(url.searchParams.get("limit") ?? 100);
205
- embedder.reindexMissing(limit).then((result) => {
178
+ // Unified re-index entry: works for both the legacy OpenAI embedder and
179
+ // the new local/ollama backends (which have no reindexMissing method).
180
+ const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
181
+ const task = viaIndex
182
+ ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
183
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
184
+ task.then((result) => {
206
185
  sendJson(res, 200, result);
207
186
  }).catch(() => {
208
187
  sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
package/src/config.js CHANGED
@@ -45,5 +45,11 @@ export const Config = z.object({
45
45
  rerankModel: z.string().default("Xenova/bge-reranker-base"),
46
46
  rerankBatchSize: z.natural().min(1).max(64).default(8),
47
47
  rerankMaxCandidates: z.natural().min(5).max(100).default(30),
48
- rerankScoreThreshold: z.number().min(0).max(1).default(0.1)
48
+ rerankScoreThreshold: z.number().min(0).max(1).default(0.1),
49
+
50
+ // --- reflection: update decision + failure tracking (v0.2.1) ------------
51
+ reflectionUpdateEnabled: z.boolean().default(true),
52
+ reflectionFailureTracking: z.boolean().default(true),
53
+ reflectionUpdateMaxPerRun: z.natural().min(0).max(5).default(2),
54
+ reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24)
49
55
  });
@@ -1,4 +1,4 @@
1
- const ACTIONS = new Set(["keep", "merge", "archive", "conflict"]);
1
+ const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
2
2
 
3
3
  /**
4
4
  * Validate a dream decision list against a snapshot of eligible memories.
@@ -6,8 +6,10 @@ const ACTIONS = new Set(["keep", "merge", "archive", "conflict"]);
6
6
  * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
7
7
  * @returns {{ok: boolean, errors: string[]}}
8
8
  */
9
- export function validateDecisions(decisions, snapshot) {
9
+ export function validateDecisions(decisions, snapshot, options = {}) {
10
10
  const errors = [];
11
+ const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
12
+ const minAgeHours = options.minAgeHours ?? 24;
11
13
  if (!Array.isArray(decisions) || decisions.length === 0) {
12
14
  return { ok: false, errors: ["decision list must be a non-empty array"] };
13
15
  }
@@ -57,6 +59,38 @@ export function validateDecisions(decisions, snapshot) {
57
59
  errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
58
60
  }
59
61
  }
62
+ if (d.action === "update") {
63
+ // 只能更新单条
64
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
65
+ errors.push(`${at}: update must target exactly one id`);
66
+ continue;
67
+ }
68
+ // 必须产生实际变化
69
+ const mem = snapshot.get(d.ids[0]);
70
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
71
+ || (d.content !== undefined && d.content !== mem?.content)
72
+ || (d.importance !== undefined && d.importance !== mem?.importance);
73
+ if (!hasChange) {
74
+ errors.push(`${at}: update must change at least one field`);
75
+ continue;
76
+ }
77
+ // 不能更新 summary
78
+ if (mem?.type === "summary") {
79
+ errors.push(`${at}: cannot update summary via update action`);
80
+ continue;
81
+ }
82
+ // 保护期:新建记忆不可立即被 update(可配置)
83
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
84
+ if (ageHours < minAgeHours) {
85
+ errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
86
+ continue;
87
+ }
88
+ }
89
+ }
90
+ // Cap update churn: too many edits in one cycle signals a runaway model
91
+ const updateCount = decisions.filter((d) => d.action === "update").length;
92
+ if (updateCount > maxUpdatePerRun) {
93
+ errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
60
94
  }
61
95
  // Every snapshot id must appear in at least one decision
62
96
  for (const id of snapshot.keys()) {
@@ -117,6 +151,21 @@ export function applyDecisions(decisions, service, logger = null) {
117
151
  });
118
152
  service.setArchived(d.loser, true);
119
153
  applied++;
154
+ } else if (d.action === "update") {
155
+ const id = d.ids[0];
156
+ const mem = service.getById(id);
157
+ if (!mem || mem.archived) continue;
158
+ // 幂等检查:如果字段已与目标一致则跳过
159
+ const same = (d.title === undefined || d.title === mem.title)
160
+ && (d.content === undefined || d.content === mem.content)
161
+ && (d.importance === undefined || d.importance === mem.importance);
162
+ if (same) continue;
163
+ service.update(id, {
164
+ title: d.title ?? mem.title,
165
+ content: d.content ?? mem.content,
166
+ importance: d.importance ?? mem.importance
167
+ });
168
+ applied++;
120
169
  }
121
170
  } catch (error) {
122
171
  // Skip individual bad decision; never corrupt the store. The optional
package/src/dream.js CHANGED
@@ -10,7 +10,13 @@ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记
10
10
  1. 识别主题相近的条目 → 输出 merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)
11
11
  2. 识别重复/过时信息 → 输出 archive
12
12
  3. 识别内容矛盾的条目 → 输出 conflict(根据时间新旧、来源完整性、信息具体程度判断 winner/loser)
13
- 4. 无问题的条目 → 输出 keep
13
+ 4. 发现单条记忆中的信息已过时、错误或遗漏 → 输出 update(直接修正内容)
14
+ - update 的 ids 只能包含一个 id
15
+ - 必须提供修正后的 title 和/或 content
16
+ - 仅当内容确实需要修正时才使用,不要滥用
17
+ - 每次整理最多输出 2 个 update
18
+ - 24 小时内新建的记忆不可 update
19
+ 5. 无问题的条目 → 输出 keep
14
20
 
15
21
  规则:
16
22
  - 每条记忆至少出现在一个决策中
@@ -18,6 +24,7 @@ const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记
18
24
  - 仅合并同类型条目(type 相同)
19
25
  - 不要编造 ids;只使用提供的 id
20
26
  - 重要性 1-5,合并后取最高
27
+ - update 只能改一条,且要有实际变化
21
28
  - 只输出 JSON 数组,不要其他文字`;
22
29
 
23
30
  function totalChars(memories) {
@@ -83,6 +90,8 @@ export function buildOutcome(decisions) {
83
90
  } else if (d.action === "conflict") {
84
91
  byId[d.winner] = "conflict-winner";
85
92
  byId[d.loser] = "conflict-archived";
93
+ } else if (d.action === "update") {
94
+ for (const id of d.ids) byId[id] = "updated";
86
95
  }
87
96
  }
88
97
  return { byId };
@@ -171,6 +180,17 @@ async function maintainIndexAfterDream(decisions, service, semantic) {
171
180
  }
172
181
  } else if (d.action === "archive" || d.action === "conflict") {
173
182
  for (const id of d.ids ?? [d.loser]) vectorIndex.deleteEmbedding(id);
183
+ } else if (d.action === "update") {
184
+ const id = d.ids[0];
185
+ const mem = service.getById(id);
186
+ if (mem) {
187
+ vectorIndex.deleteEmbedding(id);
188
+ try {
189
+ const text = [mem.title, mem.content].filter(Boolean).join("\n");
190
+ const v = await embedder.embedSingle(text);
191
+ if (v?.length) vectorIndex.saveEmbedding(id, v);
192
+ } catch { /* best-effort */ }
193
+ }
174
194
  }
175
195
  }
176
196
  for (const [id, text] of rebuild) {
@@ -369,13 +389,32 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
369
389
  logger?.warn?.("dsh-mneme dream: invalid decisions json");
370
390
  return finish({ ok: false, error: "invalid decisions json", summary: false });
371
391
  }
372
- const { ok, errors } = validateDecisions(decisions, snapshot);
392
+ const { ok, errors } = validateDecisions(decisions, snapshot, {
393
+ maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
394
+ minAgeHours: config.reflectionUpdateMinAgeHours
395
+ });
373
396
  if (!ok) {
374
397
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
375
398
  return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
376
399
  }
377
400
 
401
+ // Capture pre-update snapshots so the audit records what each update changed.
402
+ const updateSnapshots = {};
403
+ for (const d of decisions) {
404
+ if (d.action === "update") {
405
+ const mem = snapshot.get(d.ids[0]);
406
+ if (mem) updateSnapshots[d.ids[0]] = { title: mem.title, content: mem.content, importance: mem.importance };
407
+ }
408
+ }
409
+
378
410
  const applied = applyDecisions(decisions, service, logger);
411
+ // Attach the pre-update snapshot to the audit copy of each update decision
412
+ // so the recorded row shows the before/after delta, not just the target.
413
+ const auditDecisions = decisions.map((d) =>
414
+ d.action === "update" && updateSnapshots[d.ids[0]]
415
+ ? { ...d, _before: updateSnapshots[d.ids[0]] }
416
+ : d
417
+ );
379
418
  const outcome = buildOutcome(decisions);
380
419
 
381
420
  // Keep the vector index consistent with the post-dream store state.
@@ -403,7 +442,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
403
442
  });
404
443
  } catch (error) {
405
444
  logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
406
- return finish({ ok: false, error: "llm failed", applied, decisions, outcome, summary: false });
445
+ return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, summary: false });
407
446
  }
408
447
  let summaryStored = false;
409
448
  if (summaryText !== undefined && summaryText.trim()) {
@@ -421,7 +460,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
421
460
  } catch { /* best-effort */ }
422
461
  }
423
462
  }
424
- return finish({ ok: true, applied, decisions, outcome, summary: summaryStored });
463
+ return finish({ ok: true, applied, decisions: auditDecisions, outcome, summary: summaryStored });
425
464
  }
426
465
 
427
466
  return { maybeSchedule, runDream, dispose };
package/src/reranker.js CHANGED
@@ -74,6 +74,7 @@ export class LocalReranker {
74
74
  this.pipeline = null;
75
75
  this._batchScorer = null;
76
76
  this._queryVec = null;
77
+ this._queryKey = null;
77
78
  }
78
79
 
79
80
  /** Load the model; throws when no strategy can be bound. */
@@ -140,10 +141,15 @@ export class LocalReranker {
140
141
  } else {
141
142
  // Feature extraction: mean-pool the concatenated pair and compare with
142
143
  // the query embedding via cosine. Degraded but model-agnostic.
144
+ // The query vector is cached per query string, so a new query always
145
+ // recomputes it instead of reusing a stale vector from the previous call.
146
+ this._queryVec = null;
147
+ this._queryKey = null;
143
148
  this._batchScorer = async (query, passages) => {
144
- if (!this._queryVec) {
149
+ if (this._queryKey !== query) {
145
150
  const t = await this.pipeline([query], { pooling: "mean", normalize: true });
146
151
  this._queryVec = tensorToRows(t)[0];
152
+ this._queryKey = query;
147
153
  }
148
154
  const t = await this.pipeline(passages.map((p) => `${query}\n${p}`), {
149
155
  pooling: "mean",
@@ -199,5 +205,6 @@ export class LocalReranker {
199
205
  }
200
206
  this.pipeline = null;
201
207
  this._queryVec = null;
208
+ this._queryKey = null;
202
209
  }
203
210
  }
package/src/service.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
2
4
 
3
5
  export function createService({ store, mirror, config, onWrite }) {
@@ -51,12 +53,32 @@ export function createService({ store, mirror, config, onWrite }) {
51
53
  * useRerank runs the cross-encoder over the merged list when a reranker is
52
54
  * installed; results carry an extra `score` when reranked.
53
55
  */
56
+ // Weighted blend factor for hybrid search; exposed so callers can tune it.
57
+ const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
58
+
59
+ /**
60
+ * Give a keyword-hit row a relevance score in [0,1]: title hits score
61
+ * higher than content hits, then scaled by importance (1-5). This lets
62
+ * keyword results participate in weighted hybrid blends.
63
+ */
64
+ function scoreKeyword(row, q) {
65
+ const ql = q.toLowerCase();
66
+ const title = (row.title ?? "").toLowerCase();
67
+ const content = (row.content ?? "").toLowerCase();
68
+ const titleHit = title.includes(ql);
69
+ const base = titleHit ? 1 : content.includes(ql) ? 0.6 : 0.3;
70
+ return base * (0.5 + (row.importance ?? 3) / 10);
71
+ }
72
+
54
73
  async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
55
74
  const q = String(query ?? "").trim();
56
75
  if (!q) return [];
57
76
  const lim = topK > 0 ? topK : 20;
58
77
 
59
- const keyword = store.search(q, { limit: lim });
78
+ // Keyword results, decorated with a score so they can be weight-blended
79
+ // with vector results and reported uniformly.
80
+ const rawKeyword = store.search(q, { limit: lim });
81
+ const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q) }));
60
82
  const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
61
83
  let vector = [];
62
84
  if (wantVector && embedder) {
@@ -67,23 +89,44 @@ export function createService({ store, mirror, config, onWrite }) {
67
89
  : embedder.embed.bind(embedder);
68
90
  const qv = await embedSingle(q);
69
91
  if (qv?.length) {
70
- vector = vectorIndex
92
+ const hits = vectorIndex
71
93
  ? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
72
94
  : store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
95
+ vector = hits.map((m) => ({ ...m, vector: true }));
73
96
  }
74
97
  } catch { /* vector unavailable: keep keyword results */ }
75
98
  }
76
99
 
100
+ // Hybrid blending weights from config when provided.
101
+ const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
102
+ const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
103
+
77
104
  let merged;
78
105
  if (mode === "keyword") {
79
106
  merged = keyword;
80
107
  } else if (mode === "vector" || mode === "hybrid") {
81
- // semantic-first: vector recalls lead, keyword fills remaining slots
82
- merged = vector.length ? vector.slice(0, lim) : keyword;
83
- const seen = new Set(merged.map((m) => m.id));
108
+ // semantic-first: vector recalls lead, keyword fills remaining slots.
109
+ // Weighted blend when both sides scored the same memory; otherwise
110
+ // vector order leads (it is the semantic signal), keyword backfills.
111
+ const byId = new Map();
112
+ for (const m of vector) {
113
+ const rec = byId.get(m.id);
114
+ byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
115
+ }
84
116
  for (const m of keyword) {
85
- if (merged.length >= lim) break;
86
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
117
+ const rec = byId.get(m.id);
118
+ if (rec) {
119
+ // Same memory from both sides: blend the scores.
120
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
121
+ } else {
122
+ byId.set(m.id, m);
123
+ }
124
+ }
125
+ const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
126
+ merged = ranked.slice(0, lim);
127
+ if (merged.length < lim && !merged.length) {
128
+ // Vector unavailable entirely: fall back to plain keyword.
129
+ merged = keyword.slice(0, lim);
87
130
  }
88
131
  } else {
89
132
  // auto: keyword leads, vector fills remaining slots (legacy behavior)
@@ -242,7 +285,20 @@ export function createService({ store, mirror, config, onWrite }) {
242
285
  notifyWrite();
243
286
  },
244
287
  update: (id, p) => {
288
+ const old = store.getById(id);
245
289
  const updated = store.update(id, p);
290
+ // Record a user correction (only when content actually changed and the
291
+ // reflection failure tracker is enabled): expected = what it became,
292
+ // actual = what it was before. Feeds later reflection/evolution passes.
293
+ if (old && updated && config.reflectionFailureTracking && old.content !== updated.content) {
294
+ store.saveFailure({
295
+ id: randomUUID(),
296
+ expected: updated.content,
297
+ actual: old.content,
298
+ failure_type: "user_correction",
299
+ memory_id: id
300
+ });
301
+ }
246
302
  syncMirror();
247
303
  notifyWrite();
248
304
  scheduleEmbed(updated);
package/src/store.js CHANGED
@@ -40,6 +40,21 @@ CREATE TABLE IF NOT EXISTS dream_runs (
40
40
  receipt TEXT NOT NULL
41
41
  );
42
42
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
43
+
44
+ -- failure_memories: records user corrections / reflection failures. Captures
45
+ -- what a memory was ("actual") vs what the user changed it to ("expected")
46
+ -- so later reflection passes can mine recurring correction patterns.
47
+ CREATE TABLE IF NOT EXISTS failure_memories (
48
+ id TEXT PRIMARY KEY,
49
+ query TEXT,
50
+ expected TEXT,
51
+ actual TEXT,
52
+ failure_type TEXT NOT NULL,
53
+ memory_id TEXT,
54
+ created_at TEXT NOT NULL
55
+ );
56
+ CREATE INDEX IF NOT EXISTS idx_failure_memories_created ON failure_memories(created_at);
57
+ CREATE INDEX IF NOT EXISTS idx_failure_memories_type ON failure_memories(failure_type);
43
58
  `;
44
59
 
45
60
  const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
@@ -383,6 +398,45 @@ export function createStore(path) {
383
398
  return rows.map(toDreamRun);
384
399
  }
385
400
 
401
+ // --- failure memories ----------------------------------------------------
402
+
403
+ /**
404
+ * Persist one failure record (user correction, failed expectation, etc.).
405
+ * Like the dream audit trail this is bookkeeping: it never triggers write
406
+ * hooks, so reflection mining of failures cannot loop back into the writer.
407
+ */
408
+ function saveFailure({ id, query, expected, actual, failure_type, memory_id }) {
409
+ const now = nowIso();
410
+ db.prepare(
411
+ `INSERT INTO failure_memories (id, query, expected, actual, failure_type, memory_id, created_at)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
413
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, failure_type, memory_id ?? null, now);
414
+ return { id, query, expected, actual, failure_type, memory_id, created_at: now };
415
+ }
416
+
417
+ function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
418
+ const clauses = [];
419
+ const params = [];
420
+ if (since) { clauses.push("created_at >= ?"); params.push(since); }
421
+ if (memory_id) { clauses.push("memory_id = ?"); params.push(memory_id); }
422
+ if (failure_type) { clauses.push("failure_type = ?"); params.push(failure_type); }
423
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
424
+ const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
425
+ const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
426
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off);
427
+ }
428
+
429
+ function getFailureStats({ since } = {}) {
430
+ const clause = since ? "WHERE created_at >= ?" : "";
431
+ const params = since ? [since] : [];
432
+ const rows = db.prepare(
433
+ `SELECT failure_type, count(*) AS c FROM failure_memories ${clause} GROUP BY failure_type`
434
+ ).all(...params);
435
+ const stats = {};
436
+ for (const row of rows) stats[row.failure_type] = row.c;
437
+ return stats;
438
+ }
439
+
386
440
  return {
387
441
  db,
388
442
  count,
@@ -402,6 +456,9 @@ export function createStore(path) {
402
456
  saveDreamRun,
403
457
  getDreamRun,
404
458
  listDreamRuns,
459
+ saveFailure,
460
+ listFailures,
461
+ getFailureStats,
405
462
  close() {
406
463
  db.close();
407
464
  }