@modusensus/dsh-mneme 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-198%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-212%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -38,13 +38,15 @@
38
38
  ### autoDream 自动记忆整理 🧠
39
39
 
40
40
  - **触发**:记忆数 > 10 或总字符 > 5000 时,异步自动触发(不阻塞写入)
41
- - **决策清单式整理**:LLM 输出 `keep` / `merge` / `archive` / `conflict` 决策清单,服务端校验后逐条应用
41
+ - **决策清单式整理**:LLM 输出 `keep` / `merge` / `archive` / `conflict` / `update` 决策清单,服务端校验后逐条应用
42
42
  - `merge`:合并主题相近的条目,保留信息最完整者
43
43
  - `archive`:归档过时/冗余条目(可恢复,不物理删除)
44
44
  - `conflict`:裁决矛盾信息,胜者保留、败者归档并追加溯源注释
45
+ - `update`(v0.2.1):直接修正单条记忆的过时/错误内容(单 id / 必须实际变化 / 非 summary / 24h 保护 / 每次 ≤2)
46
+ - **失败追踪(v0.2.1)**:用户纠正记忆时写入 `failure_memories` 表(旧值/新值),为后续自进化积累数据
45
47
  - **摘要生成**:整理后生成"记忆库总览"(单一实例),作为下次会话的优先注入
46
48
  - **Fail-safe**:非法 LLM 输出(未知 id / 非法 action / 跨类型合并 / 越界 importance)拒绝整单,绝不破坏记忆库
47
- - **裁决审计**:每次运行写入 `dream_runs` 审计表(输入快照 sha256 digest + 完整输入快照 + 决策清单 + 逐 id 去向 + receipt),可离线回放;merge / conflict 幂等应用,重放/并发重复执行无累积副作用
49
+ - **裁决审计**:每次运行写入 `dream_runs` 审计表(输入快照 sha256 digest + 完整输入快照 + 决策清单 + 逐 id 去向 + receipt),可离线回放;merge / conflict / update 幂等应用,重放/并发重复执行无累积副作用;update 记录 `_before` 快照
48
50
 
49
51
  ### Web 记忆面板
50
52
 
@@ -204,7 +206,7 @@ src/
204
206
  lib/
205
207
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
206
208
  └── *.js # src 的同步分发产物
207
- test/ # 198 个 node:test 测试(含审计与三轴线压测不变量)
209
+ test/ # 212 个 node:test 测试(含审计与三轴线压测不变量)
208
210
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
209
211
  ```
210
212
 
@@ -213,7 +215,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
213
215
  ```bash
214
216
  cd dsh-mneme
215
217
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
216
- npm test # 运行 198 个测试
218
+ npm test # 运行 212 个测试
217
219
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
218
220
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
219
221
  ```
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
  };
@@ -64,60 +71,34 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
64
71
  const url = new URL(req.url, "http://localhost");
65
72
  const q = url.searchParams.get("q") ?? "";
66
73
  const limit = Number(url.searchParams.get("limit") ?? 20);
67
- // mode: auto (default) | keyword | vector | hybrid
74
+ // mode selects the recall strategy (defaults to auto):
75
+ // auto (default) keyword first, vector fills remaining slots
76
+ // hybrid vector first, keyword fills remaining slots; scores of
77
+ // memories hit by both sides are weight-blended
78
+ // vector vector only, falls back to keyword when the vector path
79
+ // is unavailable (no embedder or a throwing one)
80
+ // keyword literal text only; never queries the embedder
81
+ // rerank=false disables the cross-encoder reorder for this request;
82
+ // the response `mode` field reports which path actually produced rows.
68
83
  const mode = url.searchParams.get("mode") ?? "auto";
84
+ const rerank = url.searchParams.get("rerank") !== "false";
69
85
  const query = q.trim();
70
86
  if (!query) {
71
87
  sendJson(res, 200, { items: [], mode: "keyword" });
72
88
  return;
73
89
  }
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 });
90
+ // Route through the unified semantic pipeline; any vector/rerank
91
+ // failure degrades to keyword results inside searchMemories. The
92
+ // returned promise lets the test double await the async search.
93
+ return Promise.resolve(
94
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
95
+ ).then((rows) => {
96
+ // mode reflects what actually happened: rows marked `vector` came
97
+ // through the semantic path, everything else is keyword fallback.
98
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
99
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
119
100
  }).catch(() => {
120
- sendJson(res, 200, { items: keyword, mode: "keyword" });
101
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
121
102
  });
122
103
  } catch {
123
104
  sendJson(res, 500, { error: "internal" });
@@ -202,7 +183,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
202
183
  }
203
184
  const url = new URL(req.url, "http://localhost");
204
185
  const limit = Number(url.searchParams.get("limit") ?? 100);
205
- embedder.reindexMissing(limit).then((result) => {
186
+ // Unified re-index entry: works for both the legacy OpenAI embedder and
187
+ // the new local/ollama backends (which have no reindexMissing method).
188
+ const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
189
+ const task = viaIndex
190
+ ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
191
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
192
+ task.then((result) => {
206
193
  sendJson(res, 200, result);
207
194
  }).catch(() => {
208
195
  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
  }
@@ -28,6 +30,36 @@ export function validateDecisions(decisions, snapshot) {
28
30
  errors.push(`${at}: ${d.action} needs non-empty ids`);
29
31
  continue;
30
32
  }
33
+ // update-specific field validation runs BEFORE claiming ids, so a failing
34
+ // update never pollutes the claimed set (which drives the "every id must
35
+ // appear in a decision" check below).
36
+ if (d.action === "update") {
37
+ // 只能更新单条
38
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
39
+ errors.push(`${at}: update must target exactly one id`);
40
+ continue;
41
+ }
42
+ // 必须产生实际变化
43
+ const mem = snapshot.get(d.ids[0]);
44
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
45
+ || (d.content !== undefined && d.content !== mem?.content)
46
+ || (d.importance !== undefined && d.importance !== mem?.importance);
47
+ if (!hasChange) {
48
+ errors.push(`${at}: update must change at least one field`);
49
+ continue;
50
+ }
51
+ // 不能更新 summary
52
+ if (mem?.type === "summary") {
53
+ errors.push(`${at}: cannot update summary via update action`);
54
+ continue;
55
+ }
56
+ // 保护期:新建记忆不可立即被 update(可配置)
57
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
58
+ if (ageHours < minAgeHours) {
59
+ errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
60
+ continue;
61
+ }
62
+ }
31
63
  for (const id of ids) {
32
64
  const mem = snapshot.get(id);
33
65
  if (!mem) {
@@ -58,6 +90,11 @@ export function validateDecisions(decisions, snapshot) {
58
90
  }
59
91
  }
60
92
  }
93
+ // Cap update churn: too many edits in one cycle signals a runaway model
94
+ const updateCount = decisions.filter((d) => d.action === "update").length;
95
+ if (updateCount > maxUpdatePerRun) {
96
+ errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
97
+ }
61
98
  // Every snapshot id must appear in at least one decision
62
99
  for (const id of snapshot.keys()) {
63
100
  if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
@@ -117,6 +154,21 @@ export function applyDecisions(decisions, service, logger = null) {
117
154
  });
118
155
  service.setArchived(d.loser, true);
119
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++;
120
172
  }
121
173
  } catch (error) {
122
174
  // 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)
@@ -241,8 +284,28 @@ export function createService({ store, mirror, config, onWrite }) {
241
284
  syncMirror();
242
285
  notifyWrite();
243
286
  },
244
- update: (id, p) => {
287
+ update: (id, p, ctx = {}) => {
288
+ const old = store.getById(id);
245
289
  const updated = store.update(id, p);
290
+ // Record a user correction when any meaningful field changed and the
291
+ // reflection failure tracker is enabled. expected = what it became,
292
+ // actual = what it was before; query (when provided) captures the
293
+ // user's original intent so later reflection can reason about recall.
294
+ const hasMeaningfulChange = old && updated && (
295
+ old.content !== updated.content ||
296
+ old.title !== updated.title ||
297
+ old.importance !== updated.importance
298
+ );
299
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
300
+ store.saveFailure({
301
+ id: randomUUID(),
302
+ query: ctx.query ?? null,
303
+ expected: updated.content,
304
+ actual: old.content,
305
+ failure_type: "user_correction",
306
+ memory_id: id
307
+ });
308
+ }
246
309
  syncMirror();
247
310
  notifyWrite();
248
311
  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,50 @@ 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
+ /** Delete failure rows older than `before` (ISO string). Returns count removed. */
430
+ function deleteOldFailures(before) {
431
+ return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
432
+ }
433
+
434
+ function getFailureStats({ since } = {}) {
435
+ const clause = since ? "WHERE created_at >= ?" : "";
436
+ const params = since ? [since] : [];
437
+ const rows = db.prepare(
438
+ `SELECT failure_type, count(*) AS c FROM failure_memories ${clause} GROUP BY failure_type`
439
+ ).all(...params);
440
+ const stats = {};
441
+ for (const row of rows) stats[row.failure_type] = row.c;
442
+ return stats;
443
+ }
444
+
386
445
  return {
387
446
  db,
388
447
  count,
@@ -402,6 +461,10 @@ export function createStore(path) {
402
461
  saveDreamRun,
403
462
  getDreamRun,
404
463
  listDreamRuns,
464
+ saveFailure,
465
+ listFailures,
466
+ getFailureStats,
467
+ deleteOldFailures,
405
468
  close() {
406
469
  db.close();
407
470
  }
package/lib/tools.js CHANGED
@@ -133,7 +133,8 @@ export function createTools(ctx, service, config, embedder) {
133
133
  content: { type: "string" },
134
134
  type: { type: "string", enum: ["preference", "project", "decision", "history"] },
135
135
  tags: { type: "array", items: { type: "string" } },
136
- importance: { type: "integer", description: "1-5" }
136
+ importance: { type: "integer", description: "1-5" },
137
+ reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
137
138
  },
138
139
  output: {
139
140
  schema: {
@@ -160,7 +161,7 @@ export function createTools(ctx, service, config, embedder) {
160
161
  type: args.type,
161
162
  tags: args.tags,
162
163
  importance: args.importance
163
- });
164
+ }, { query: args.reason });
164
165
  return { memory: { id: memory.id, title: memory.title, content: memory.content } };
165
166
  }
166
167
  }),
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.2",
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
  };
@@ -64,60 +71,34 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
64
71
  const url = new URL(req.url, "http://localhost");
65
72
  const q = url.searchParams.get("q") ?? "";
66
73
  const limit = Number(url.searchParams.get("limit") ?? 20);
67
- // mode: auto (default) | keyword | vector | hybrid
74
+ // mode selects the recall strategy (defaults to auto):
75
+ // auto (default) keyword first, vector fills remaining slots
76
+ // hybrid vector first, keyword fills remaining slots; scores of
77
+ // memories hit by both sides are weight-blended
78
+ // vector vector only, falls back to keyword when the vector path
79
+ // is unavailable (no embedder or a throwing one)
80
+ // keyword literal text only; never queries the embedder
81
+ // rerank=false disables the cross-encoder reorder for this request;
82
+ // the response `mode` field reports which path actually produced rows.
68
83
  const mode = url.searchParams.get("mode") ?? "auto";
84
+ const rerank = url.searchParams.get("rerank") !== "false";
69
85
  const query = q.trim();
70
86
  if (!query) {
71
87
  sendJson(res, 200, { items: [], mode: "keyword" });
72
88
  return;
73
89
  }
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 });
90
+ // Route through the unified semantic pipeline; any vector/rerank
91
+ // failure degrades to keyword results inside searchMemories. The
92
+ // returned promise lets the test double await the async search.
93
+ return Promise.resolve(
94
+ service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
95
+ ).then((rows) => {
96
+ // mode reflects what actually happened: rows marked `vector` came
97
+ // through the semantic path, everything else is keyword fallback.
98
+ const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
99
+ sendJson(res, 200, { items: service.toApiList(rows), mode: used });
119
100
  }).catch(() => {
120
- sendJson(res, 200, { items: keyword, mode: "keyword" });
101
+ sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
121
102
  });
122
103
  } catch {
123
104
  sendJson(res, 500, { error: "internal" });
@@ -202,7 +183,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
202
183
  }
203
184
  const url = new URL(req.url, "http://localhost");
204
185
  const limit = Number(url.searchParams.get("limit") ?? 100);
205
- embedder.reindexMissing(limit).then((result) => {
186
+ // Unified re-index entry: works for both the legacy OpenAI embedder and
187
+ // the new local/ollama backends (which have no reindexMissing method).
188
+ const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
189
+ const task = viaIndex
190
+ ? semantic.vectorIndex.rebuildIndex(embedder, { limit })
191
+ : embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
192
+ task.then((result) => {
206
193
  sendJson(res, 200, result);
207
194
  }).catch(() => {
208
195
  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
  }
@@ -28,6 +30,36 @@ export function validateDecisions(decisions, snapshot) {
28
30
  errors.push(`${at}: ${d.action} needs non-empty ids`);
29
31
  continue;
30
32
  }
33
+ // update-specific field validation runs BEFORE claiming ids, so a failing
34
+ // update never pollutes the claimed set (which drives the "every id must
35
+ // appear in a decision" check below).
36
+ if (d.action === "update") {
37
+ // 只能更新单条
38
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
39
+ errors.push(`${at}: update must target exactly one id`);
40
+ continue;
41
+ }
42
+ // 必须产生实际变化
43
+ const mem = snapshot.get(d.ids[0]);
44
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
45
+ || (d.content !== undefined && d.content !== mem?.content)
46
+ || (d.importance !== undefined && d.importance !== mem?.importance);
47
+ if (!hasChange) {
48
+ errors.push(`${at}: update must change at least one field`);
49
+ continue;
50
+ }
51
+ // 不能更新 summary
52
+ if (mem?.type === "summary") {
53
+ errors.push(`${at}: cannot update summary via update action`);
54
+ continue;
55
+ }
56
+ // 保护期:新建记忆不可立即被 update(可配置)
57
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
58
+ if (ageHours < minAgeHours) {
59
+ errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
60
+ continue;
61
+ }
62
+ }
31
63
  for (const id of ids) {
32
64
  const mem = snapshot.get(id);
33
65
  if (!mem) {
@@ -58,6 +90,11 @@ export function validateDecisions(decisions, snapshot) {
58
90
  }
59
91
  }
60
92
  }
93
+ // Cap update churn: too many edits in one cycle signals a runaway model
94
+ const updateCount = decisions.filter((d) => d.action === "update").length;
95
+ if (updateCount > maxUpdatePerRun) {
96
+ errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
97
+ }
61
98
  // Every snapshot id must appear in at least one decision
62
99
  for (const id of snapshot.keys()) {
63
100
  if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
@@ -117,6 +154,21 @@ export function applyDecisions(decisions, service, logger = null) {
117
154
  });
118
155
  service.setArchived(d.loser, true);
119
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++;
120
172
  }
121
173
  } catch (error) {
122
174
  // 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)
@@ -241,8 +284,28 @@ export function createService({ store, mirror, config, onWrite }) {
241
284
  syncMirror();
242
285
  notifyWrite();
243
286
  },
244
- update: (id, p) => {
287
+ update: (id, p, ctx = {}) => {
288
+ const old = store.getById(id);
245
289
  const updated = store.update(id, p);
290
+ // Record a user correction when any meaningful field changed and the
291
+ // reflection failure tracker is enabled. expected = what it became,
292
+ // actual = what it was before; query (when provided) captures the
293
+ // user's original intent so later reflection can reason about recall.
294
+ const hasMeaningfulChange = old && updated && (
295
+ old.content !== updated.content ||
296
+ old.title !== updated.title ||
297
+ old.importance !== updated.importance
298
+ );
299
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
300
+ store.saveFailure({
301
+ id: randomUUID(),
302
+ query: ctx.query ?? null,
303
+ expected: updated.content,
304
+ actual: old.content,
305
+ failure_type: "user_correction",
306
+ memory_id: id
307
+ });
308
+ }
246
309
  syncMirror();
247
310
  notifyWrite();
248
311
  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,50 @@ 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
+ /** Delete failure rows older than `before` (ISO string). Returns count removed. */
430
+ function deleteOldFailures(before) {
431
+ return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
432
+ }
433
+
434
+ function getFailureStats({ since } = {}) {
435
+ const clause = since ? "WHERE created_at >= ?" : "";
436
+ const params = since ? [since] : [];
437
+ const rows = db.prepare(
438
+ `SELECT failure_type, count(*) AS c FROM failure_memories ${clause} GROUP BY failure_type`
439
+ ).all(...params);
440
+ const stats = {};
441
+ for (const row of rows) stats[row.failure_type] = row.c;
442
+ return stats;
443
+ }
444
+
386
445
  return {
387
446
  db,
388
447
  count,
@@ -402,6 +461,10 @@ export function createStore(path) {
402
461
  saveDreamRun,
403
462
  getDreamRun,
404
463
  listDreamRuns,
464
+ saveFailure,
465
+ listFailures,
466
+ getFailureStats,
467
+ deleteOldFailures,
405
468
  close() {
406
469
  db.close();
407
470
  }
package/src/tools.js CHANGED
@@ -133,7 +133,8 @@ export function createTools(ctx, service, config, embedder) {
133
133
  content: { type: "string" },
134
134
  type: { type: "string", enum: ["preference", "project", "decision", "history"] },
135
135
  tags: { type: "array", items: { type: "string" } },
136
- importance: { type: "integer", description: "1-5" }
136
+ importance: { type: "integer", description: "1-5" },
137
+ reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
137
138
  },
138
139
  output: {
139
140
  schema: {
@@ -160,7 +161,7 @@ export function createTools(ctx, service, config, embedder) {
160
161
  type: args.type,
161
162
  tags: args.tags,
162
163
  importance: args.importance
163
- });
164
+ }, { query: args.reason });
164
165
  return { memory: { id: memory.id, title: memory.title, content: memory.content } };
165
166
  }
166
167
  }),