@modusensus/dsh-mneme 0.2.1 → 0.2.3

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
@@ -71,7 +71,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
71
71
  const url = new URL(req.url, "http://localhost");
72
72
  const q = url.searchParams.get("q") ?? "";
73
73
  const limit = Number(url.searchParams.get("limit") ?? 20);
74
- // 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.
75
83
  const mode = url.searchParams.get("mode") ?? "auto";
76
84
  const rerank = url.searchParams.get("rerank") !== "false";
77
85
  const query = q.trim();
@@ -30,6 +30,36 @@ export function validateDecisions(decisions, snapshot, options = {}) {
30
30
  errors.push(`${at}: ${d.action} needs non-empty ids`);
31
31
  continue;
32
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
+ }
33
63
  for (const id of ids) {
34
64
  const mem = snapshot.get(id);
35
65
  if (!mem) {
@@ -59,33 +89,6 @@ export function validateDecisions(decisions, snapshot, options = {}) {
59
89
  errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
60
90
  }
61
91
  }
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
92
  }
90
93
  // Cap update churn: too many edits in one cycle signals a runaway model
91
94
  const updateCount = decisions.filter((d) => d.action === "update").length;
package/lib/index.js CHANGED
@@ -36,6 +36,11 @@ export const apply = (ctx, config) => {
36
36
  mkdirSync(memoryDir, { recursive: true });
37
37
 
38
38
  const store = createStore(join(memoryDir, "memory.db"));
39
+ // Prune reflection failure rows older than 90 days on boot (best-effort, so
40
+ // the failure table never grows unbounded).
41
+ try {
42
+ store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
43
+ } catch { /* non-fatal */ }
39
44
  const mirror = createMirror(memoryDir);
40
45
  const service = createService({ store, mirror, config: cfg });
41
46
 
package/lib/service.js CHANGED
@@ -284,17 +284,25 @@ export function createService({ store, mirror, config, onWrite }) {
284
284
  syncMirror();
285
285
  notifyWrite();
286
286
  },
287
- update: (id, p) => {
287
+ update: (id, p, ctx = {}) => {
288
288
  const old = store.getById(id);
289
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) {
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) {
294
300
  store.saveFailure({
295
301
  id: randomUUID(),
302
+ query: ctx.query ?? null,
296
303
  expected: updated.content,
297
304
  actual: old.content,
305
+ before: { title: old.title, content: old.content, importance: old.importance },
298
306
  failure_type: "user_correction",
299
307
  memory_id: id
300
308
  });
package/lib/store.js CHANGED
@@ -42,13 +42,16 @@ CREATE TABLE IF NOT EXISTS dream_runs (
42
42
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
43
43
 
44
44
  -- failure_memories: records user corrections / reflection failures. Captures
45
- -- what a memory was ("actual") vs what the user changed it to ("expected")
45
+ -- what a memory was (actual) vs what the user changed it to (expected)
46
46
  -- so later reflection passes can mine recurring correction patterns.
47
+ -- before holds a JSON snapshot of the pre-change title/content/importance,
48
+ -- so a title-only or importance-only correction is still traceable.
47
49
  CREATE TABLE IF NOT EXISTS failure_memories (
48
50
  id TEXT PRIMARY KEY,
49
51
  query TEXT,
50
52
  expected TEXT,
51
53
  actual TEXT,
54
+ before TEXT,
52
55
  failure_type TEXT NOT NULL,
53
56
  memory_id TEXT,
54
57
  created_at TEXT NOT NULL
@@ -405,13 +408,14 @@ export function createStore(path) {
405
408
  * Like the dream audit trail this is bookkeeping: it never triggers write
406
409
  * hooks, so reflection mining of failures cannot loop back into the writer.
407
410
  */
408
- function saveFailure({ id, query, expected, actual, failure_type, memory_id }) {
411
+ function saveFailure({ id, query, expected, actual, before, failure_type, memory_id }) {
409
412
  const now = nowIso();
413
+ const beforeJson = before && typeof before === "object" ? JSON.stringify(before) : (before ?? null);
410
414
  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
+ `INSERT INTO failure_memories (id, query, expected, actual, before, failure_type, memory_id, created_at)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
417
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, beforeJson, failure_type, memory_id ?? null, now);
418
+ return { id, query, expected, actual, before: before ?? null, failure_type, memory_id, created_at: now };
415
419
  }
416
420
 
417
421
  function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
@@ -423,7 +427,17 @@ export function createStore(path) {
423
427
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
424
428
  const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
425
429
  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);
430
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off)
431
+ .map((row) => {
432
+ let before;
433
+ try { before = row.before ? JSON.parse(row.before) : null; } catch { before = null; }
434
+ return { ...row, before };
435
+ });
436
+ }
437
+
438
+ /** Delete failure rows older than `before` (ISO string). Returns count removed. */
439
+ function deleteOldFailures(before) {
440
+ return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
427
441
  }
428
442
 
429
443
  function getFailureStats({ since } = {}) {
@@ -459,6 +473,7 @@ export function createStore(path) {
459
473
  saveFailure,
460
474
  listFailures,
461
475
  getFailureStats,
476
+ deleteOldFailures,
462
477
  close() {
463
478
  db.close();
464
479
  }
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
  }),
@@ -73,7 +73,7 @@ export function createVectorIndex({ store, logger }) {
73
73
 
74
74
  /** Re-embed every row missing an embedding. Returns indexed count. */
75
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
76
- if (!embedder || typeof embedder.embed !== "function") return { indexed: 0, skipped: 0 };
76
+ if (!embedder || typeof embedder.embedSingle !== "function") return { indexed: 0, skipped: 0 };
77
77
  const rows = store.needsEmbedding(limit);
78
78
  let indexed = 0;
79
79
  for (const row of rows) {
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.1",
4
+ "version": "0.2.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -71,7 +71,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
71
71
  const url = new URL(req.url, "http://localhost");
72
72
  const q = url.searchParams.get("q") ?? "";
73
73
  const limit = Number(url.searchParams.get("limit") ?? 20);
74
- // 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.
75
83
  const mode = url.searchParams.get("mode") ?? "auto";
76
84
  const rerank = url.searchParams.get("rerank") !== "false";
77
85
  const query = q.trim();
@@ -30,6 +30,36 @@ export function validateDecisions(decisions, snapshot, options = {}) {
30
30
  errors.push(`${at}: ${d.action} needs non-empty ids`);
31
31
  continue;
32
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
+ }
33
63
  for (const id of ids) {
34
64
  const mem = snapshot.get(id);
35
65
  if (!mem) {
@@ -59,33 +89,6 @@ export function validateDecisions(decisions, snapshot, options = {}) {
59
89
  errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
60
90
  }
61
91
  }
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
92
  }
90
93
  // Cap update churn: too many edits in one cycle signals a runaway model
91
94
  const updateCount = decisions.filter((d) => d.action === "update").length;
package/src/index.js CHANGED
@@ -36,6 +36,11 @@ export const apply = (ctx, config) => {
36
36
  mkdirSync(memoryDir, { recursive: true });
37
37
 
38
38
  const store = createStore(join(memoryDir, "memory.db"));
39
+ // Prune reflection failure rows older than 90 days on boot (best-effort, so
40
+ // the failure table never grows unbounded).
41
+ try {
42
+ store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
43
+ } catch { /* non-fatal */ }
39
44
  const mirror = createMirror(memoryDir);
40
45
  const service = createService({ store, mirror, config: cfg });
41
46
 
package/src/service.js CHANGED
@@ -284,17 +284,25 @@ export function createService({ store, mirror, config, onWrite }) {
284
284
  syncMirror();
285
285
  notifyWrite();
286
286
  },
287
- update: (id, p) => {
287
+ update: (id, p, ctx = {}) => {
288
288
  const old = store.getById(id);
289
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) {
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) {
294
300
  store.saveFailure({
295
301
  id: randomUUID(),
302
+ query: ctx.query ?? null,
296
303
  expected: updated.content,
297
304
  actual: old.content,
305
+ before: { title: old.title, content: old.content, importance: old.importance },
298
306
  failure_type: "user_correction",
299
307
  memory_id: id
300
308
  });
package/src/store.js CHANGED
@@ -42,13 +42,16 @@ CREATE TABLE IF NOT EXISTS dream_runs (
42
42
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
43
43
 
44
44
  -- failure_memories: records user corrections / reflection failures. Captures
45
- -- what a memory was ("actual") vs what the user changed it to ("expected")
45
+ -- what a memory was (actual) vs what the user changed it to (expected)
46
46
  -- so later reflection passes can mine recurring correction patterns.
47
+ -- before holds a JSON snapshot of the pre-change title/content/importance,
48
+ -- so a title-only or importance-only correction is still traceable.
47
49
  CREATE TABLE IF NOT EXISTS failure_memories (
48
50
  id TEXT PRIMARY KEY,
49
51
  query TEXT,
50
52
  expected TEXT,
51
53
  actual TEXT,
54
+ before TEXT,
52
55
  failure_type TEXT NOT NULL,
53
56
  memory_id TEXT,
54
57
  created_at TEXT NOT NULL
@@ -405,13 +408,14 @@ export function createStore(path) {
405
408
  * Like the dream audit trail this is bookkeeping: it never triggers write
406
409
  * hooks, so reflection mining of failures cannot loop back into the writer.
407
410
  */
408
- function saveFailure({ id, query, expected, actual, failure_type, memory_id }) {
411
+ function saveFailure({ id, query, expected, actual, before, failure_type, memory_id }) {
409
412
  const now = nowIso();
413
+ const beforeJson = before && typeof before === "object" ? JSON.stringify(before) : (before ?? null);
410
414
  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
+ `INSERT INTO failure_memories (id, query, expected, actual, before, failure_type, memory_id, created_at)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
417
+ ).run(id ?? randomUUID(), query ?? null, expected ?? null, actual ?? null, beforeJson, failure_type, memory_id ?? null, now);
418
+ return { id, query, expected, actual, before: before ?? null, failure_type, memory_id, created_at: now };
415
419
  }
416
420
 
417
421
  function listFailures({ limit = 50, offset = 0, since, memory_id, failure_type } = {}) {
@@ -423,7 +427,17 @@ export function createStore(path) {
423
427
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
424
428
  const lim = Number.isInteger(limit) && limit > 0 ? limit : 50;
425
429
  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);
430
+ return db.prepare(`SELECT * FROM failure_memories ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`).all(...params, lim, off)
431
+ .map((row) => {
432
+ let before;
433
+ try { before = row.before ? JSON.parse(row.before) : null; } catch { before = null; }
434
+ return { ...row, before };
435
+ });
436
+ }
437
+
438
+ /** Delete failure rows older than `before` (ISO string). Returns count removed. */
439
+ function deleteOldFailures(before) {
440
+ return db.prepare("DELETE FROM failure_memories WHERE created_at < ?").run(before).changes;
427
441
  }
428
442
 
429
443
  function getFailureStats({ since } = {}) {
@@ -459,6 +473,7 @@ export function createStore(path) {
459
473
  saveFailure,
460
474
  listFailures,
461
475
  getFailureStats,
476
+ deleteOldFailures,
462
477
  close() {
463
478
  db.close();
464
479
  }
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
  }),
@@ -73,7 +73,7 @@ export function createVectorIndex({ store, logger }) {
73
73
 
74
74
  /** Re-embed every row missing an embedding. Returns indexed count. */
75
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
76
- if (!embedder || typeof embedder.embed !== "function") return { indexed: 0, skipped: 0 };
76
+ if (!embedder || typeof embedder.embedSingle !== "function") return { indexed: 0, skipped: 0 };
77
77
  const rows = store.needsEmbedding(limit);
78
78
  let indexed = 0;
79
79
  for (const row of rows) {