@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 +7 -5
- package/lib/api.js +35 -48
- package/lib/config.js +7 -1
- package/lib/dream/decisions.js +54 -2
- package/lib/dream.js +43 -4
- package/lib/reranker.js +8 -1
- package/lib/service.js +71 -8
- package/lib/store.js +63 -0
- package/lib/tools.js +3 -2
- package/package.json +1 -1
- package/src/api.js +35 -48
- package/src/config.js +7 -1
- package/src/dream/decisions.js +54 -2
- package/src/dream.js +43 -4
- package/src/reranker.js +8 -1
- package/src/service.js +71 -8
- package/src/store.js +63 -0
- package/src/tools.js +3 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](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/ #
|
|
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 # 运行
|
|
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
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
sendJson(res, 200, { items:
|
|
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:
|
|
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
|
|
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
|
});
|
package/lib/dream/decisions.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
if (
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
sendJson(res, 200, { items:
|
|
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:
|
|
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
|
|
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
|
});
|
package/src/dream/decisions.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
if (
|
|
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
|
}),
|