@modusensus/dsh-mneme 0.5.1 → 0.5.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 +26 -6
- package/lib/config.js +10 -6
- package/lib/dream.js +94 -2
- package/lib/service.js +3 -0
- package/lib/store.js +6 -2
- package/lib/summarize.js +5 -1
- package/lib/tools.js +7 -2
- package/package.json +1 -1
- package/src/config.js +10 -6
- package/src/dream.js +94 -2
- package/src/service.js +3 -0
- package/src/store.js +6 -2
- package/src/summarize.js +5 -1
- package/src/tools.js +7 -2
- package/test/normalize-decisions.test.js +120 -0
- package/test/provenance.test.js +103 -0
- package/test/reasoning-effort.test.js +27 -0
package/README.md
CHANGED
|
@@ -5,12 +5,32 @@
|
|
|
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
|
|
|
12
12
|
`dsh-mneme` 是一个 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 插件,为 Agent 提供持久的跨会话记忆能力。它借鉴了 Claude 的 **Dream 机制** 与 cc-haha / Claude Code 的 **autoDream** 实现思路——不仅**存储**记忆,还会**自动巩固**(去重、合并、冲突裁决、摘要生成),让记忆库越用越精炼。
|
|
13
13
|
|
|
14
|
+
## ⏱️ 30 秒理解
|
|
15
|
+
|
|
16
|
+
**一句话**:给 Agent 装上跨会话记忆——记住你、记住项目,并在后台自动整理,越用越懂你。
|
|
17
|
+
|
|
18
|
+
| ① 写入 | ② 存储 | ③ 进化 |
|
|
19
|
+
|--------|--------|--------|
|
|
20
|
+
| 对话中模型主动记录(`memory_save`);会话结束自动提炼(`autoSummarize`) | SQLite 主库 + 人类可编辑 Markdown 镜像;实体 / 属性 / 时间轴三层结构化 | 新会话自动注入相关记忆;autoDream 后台去重 / 合并 / 归档,记忆库自我精炼 |
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 30 秒上手
|
|
24
|
+
dsh plugin --profile web add @modusensus/dsh-mneme
|
|
25
|
+
dsh web
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**它不是什么**(边界声明):
|
|
29
|
+
|
|
30
|
+
- 不是向量数据库——语义搜索是可选增强,默认零额外依赖
|
|
31
|
+
- 不替代会话日志——它存的是「值得跨会话记住的」精炼知识
|
|
32
|
+
- 不改变模型本身——进化的是记忆库与每次注入的上下文
|
|
33
|
+
|
|
14
34
|
## ✨ 功能
|
|
15
35
|
|
|
16
36
|
### 记忆存储(SQLite + Markdown 镜像)
|
|
@@ -64,7 +84,7 @@
|
|
|
64
84
|
| 中等(1 万-5 万字) | `65536` |
|
|
65
85
|
| 大型(5 万字以上) | `131072`(上限) |
|
|
66
86
|
|
|
67
|
-
> 若使用**思考型模型**(如 DeepSeek-R1 类),模型可能把全部预算花在 reasoning 上导致正文为空(日志出现 `no json array in llm output`)。此时把 `dreamReasoningEffort` 设为 `
|
|
87
|
+
> 若使用**思考型模型**(如 deepseek-v4-flash / DeepSeek-R1 类),模型可能把全部预算花在 reasoning 上导致正文为空(日志出现 `no json array in llm output`)。此时把 `dreamReasoningEffort` 设为 `off` 显式关闭思考(实测 8192 token 预算不再被推理吃光,输出恢复正常);sleep 侧对应 `sleepReasoningEffort`。默认 `none` 不传该字段,完全沿用模型自身默认,行为与旧版本一致。
|
|
68
88
|
|
|
69
89
|
### Sleep Mode 系统级睡眠 💤(v0.4.0,opt-in)
|
|
70
90
|
|
|
@@ -277,8 +297,8 @@ dsh web
|
|
|
277
297
|
| `dreamThresholdChars` | `5000` | 触发整理的总字符阈值 |
|
|
278
298
|
| `dreamDelayMs` | `2000` | 整理异步延迟(去抖) |
|
|
279
299
|
| `dreamProvider` / `dreamModel` | 空 | dream 的 LLM 路由回退(默认用 agent 默认模型) |
|
|
280
|
-
| `dreamMaxTokens` | `
|
|
281
|
-
| `dreamReasoningEffort` | `none` | dream LLM 推理强度透传:`low` / `medium` / `high` / `none`(`none
|
|
300
|
+
| `dreamMaxTokens` | `8192` | dream LLM 调用最大 token 数(上限 131072;大记忆量建议调大,见下方调优指南) |
|
|
301
|
+
| `dreamReasoningEffort` | `none` | dream LLM 推理强度透传:`off` / `low` / `medium` / `high` / `none`(`off`=显式关闭思考,思考型模型(如 deepseek-v4-flash)必须用它,否则推理会烧光 token 预算导致正文为空;`none`=不传该字段,沿用模型默认) |
|
|
282
302
|
| `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer <apiToken>` |
|
|
283
303
|
| `embedProvider` | `openai` | 语义后端:`openai`(默认,兼容 v0.1)/ `local`(ONNX 离线)/ `ollama` |
|
|
284
304
|
| `localEmbedModel` | `Xenova/bge-small-zh-v1.5` | 本地 ONNX embedding 模型 |
|
|
@@ -367,7 +387,7 @@ src/
|
|
|
367
387
|
lib/
|
|
368
388
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
369
389
|
└── *.js # src 的同步分发产物
|
|
370
|
-
test/ #
|
|
390
|
+
test/ # 603 个 node:test 测试(含审计与三轴线压测不变量)
|
|
371
391
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · benchmark-recall.js 召回基准
|
|
372
392
|
```
|
|
373
393
|
|
|
@@ -376,7 +396,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
376
396
|
```bash
|
|
377
397
|
cd dsh-mneme
|
|
378
398
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
379
|
-
npm test # 运行
|
|
399
|
+
npm test # 运行 603 个测试
|
|
380
400
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
381
401
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
382
402
|
```
|
package/lib/config.js
CHANGED
|
@@ -19,11 +19,13 @@ export const Config = z.object({
|
|
|
19
19
|
dreamModel: z.string(),
|
|
20
20
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
21
21
|
// Pass-through reasoning effort for dream's LLM calls. 'none' (default)
|
|
22
|
-
// omits the field so the provider's own default applies;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// empty body ("no json array in llm output")
|
|
22
|
+
// omits the field so the provider's own default applies; 'off' explicitly
|
|
23
|
+
// disables thinking — REQUIRED for thinking-type models (deepseek-v4-flash
|
|
24
|
+
// etc.) that would otherwise drain the whole token budget into reasoning and
|
|
25
|
+
// return an empty body ("no json array in llm output"); low/medium/high are
|
|
26
|
+
// forwarded verbatim to cap reasoning spend.
|
|
26
27
|
dreamReasoningEffort: z.union([
|
|
28
|
+
z.const("off"),
|
|
27
29
|
z.const("low"),
|
|
28
30
|
z.const("medium"),
|
|
29
31
|
z.const("high"),
|
|
@@ -197,9 +199,11 @@ export const Config = z.object({
|
|
|
197
199
|
sleepProvider: z.string().default(""),
|
|
198
200
|
sleepModel: z.string().default(""),
|
|
199
201
|
// Pass-through reasoning effort for sleep's LLM passes, same semantics as
|
|
200
|
-
// dreamReasoningEffort: 'none' (default) omits the field;
|
|
201
|
-
//
|
|
202
|
+
// dreamReasoningEffort: 'none' (default) omits the field; 'off' explicitly
|
|
203
|
+
// disables thinking (thinking-type models would burn the whole budget on
|
|
204
|
+
// reasoning); low/medium/high are forwarded verbatim.
|
|
202
205
|
sleepReasoningEffort: z.union([
|
|
206
|
+
z.const("off"),
|
|
203
207
|
z.const("low"),
|
|
204
208
|
z.const("medium"),
|
|
205
209
|
z.const("high"),
|
package/lib/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions };
|
|
4
|
+
export { validateDecisions, applyDecisions, normalizeDecisions };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -40,6 +40,95 @@ function extractJsonArray(text) {
|
|
|
40
40
|
}
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Field-name drift guard (v0.5.3): thinking-type models (deepseek-v4-flash
|
|
46
|
+
* etc.) occasionally ignore the prompt's exact decision schema and emit alias
|
|
47
|
+
* keys —实测方案 A 输出 "consolidation"/"target_ids"、方案 B 输出
|
|
48
|
+
* "action"/"targetIds",都不是插件要求的 "action"/"ids"。normalizeDecisions
|
|
49
|
+
* 在 validateDecisions 之前把常见变体重写回规范字段名,让"字段名不听话但
|
|
50
|
+
* 语义正确"的输出仍可被应用,而不是整单被拒。覆盖三类漂移:
|
|
51
|
+
* 1. 整个 body 是包在对象里的数组({consolidation:[...]} /
|
|
52
|
+
* {decisions:[...]} / {actions:[...]});
|
|
53
|
+
* 2. 决策对象用了别名键(target_ids→ids、keep_source→keepSource、
|
|
54
|
+
* winner_id→winner、targetIds→ids 等);
|
|
55
|
+
* 3. action 值用了同义词(archived→archive、consolidation→merge 等)。
|
|
56
|
+
* 无归一化必要时原样返回(null→null,调用方"no json array"分支不受影响)。
|
|
57
|
+
*/
|
|
58
|
+
const FIELD_ALIASES = {
|
|
59
|
+
// "consolidation" 也可作 action 键(实测 deepseek-v4-flash 这么写过);
|
|
60
|
+
// 但 normalizeOne 对 action 键做字符串过滤,{consolidation:[...]} 这种
|
|
61
|
+
// wrapper 数组不会被误当成 action(顶层 WRAPPER_KEYS 负责解包)。
|
|
62
|
+
action: ["action", "decision", "operation", "op", "action_type", "actionType", "mode", "consolidation"],
|
|
63
|
+
// 注意:action 别名不含 "type"——create 决策的 type 是合法的记忆类型字段,
|
|
64
|
+
// 不能误当 action。
|
|
65
|
+
ids: ["ids", "target_ids", "targetIds", "targets", "memory_ids", "memoryIds", "id_list", "idList", "memories"],
|
|
66
|
+
reason: ["reason", "rationale", "why", "comment", "note", "explanation"],
|
|
67
|
+
importance: ["importance", "priority", "weight", "level", "score"],
|
|
68
|
+
keepSource: ["keepSource", "keep_source", "source_id", "sourceId", "keeper", "keep_id", "keepId"],
|
|
69
|
+
winner: ["winner", "winner_id", "winnerId", "win_id", "winId", "preferred", "primary"],
|
|
70
|
+
loser: ["loser", "loser_id", "loserId", "lose_id", "loseId", "drop_id", "dropId", "archive_id", "archiveId"],
|
|
71
|
+
title: ["title", "new_title", "newTitle", "merged_title", "mergedTitle", "merge_title", "mergeTitle"],
|
|
72
|
+
content: ["content", "new_content", "newContent", "merged_content", "mergedContent", "merge_content", "mergeContent"],
|
|
73
|
+
evidence: ["evidence", "evidence_ids", "evidenceIds"]
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 保守的 action 值同义词:只收语义无歧义、不可能被误认为记忆类型/其他 action 的映射。
|
|
77
|
+
const ACTION_SYNONYMS = {
|
|
78
|
+
archived: "archive",
|
|
79
|
+
remove: "archive",
|
|
80
|
+
delete: "archive",
|
|
81
|
+
combine: "merge",
|
|
82
|
+
consolidation: "merge",
|
|
83
|
+
consolidate: "merge",
|
|
84
|
+
modify: "update",
|
|
85
|
+
edit: "update"
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const WRAPPER_KEYS = ["consolidation", "decisions", "actions", "results", "updates", "list", "data"];
|
|
89
|
+
|
|
90
|
+
/** 把单个决策对象重写到规范字段名;非对象原样返回。 */
|
|
91
|
+
function normalizeOne(raw) {
|
|
92
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
93
|
+
const d = {};
|
|
94
|
+
const consumed = new Set();
|
|
95
|
+
for (const [canon, aliases] of Object.entries(FIELD_ALIASES)) {
|
|
96
|
+
for (const key of aliases) {
|
|
97
|
+
if (key in raw && raw[key] !== undefined && raw[key] !== null) {
|
|
98
|
+
// action 必须是字符串——{consolidation:[...]} 的 wrapper 数组跳过。
|
|
99
|
+
if (canon === "action" && Array.isArray(raw[key])) continue;
|
|
100
|
+
d[canon] = raw[key];
|
|
101
|
+
consumed.add(key);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 保留未被别名消费的原始键(create 的 type、未来字段)原样透传。
|
|
107
|
+
for (const key of Object.keys(raw)) {
|
|
108
|
+
if (!consumed.has(key)) d[key] = raw[key];
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.action === "string") {
|
|
111
|
+
const low = d.action.toLowerCase();
|
|
112
|
+
if (ACTION_SYNONYMS[low] !== undefined) d.action = ACTION_SYNONYMS[low];
|
|
113
|
+
}
|
|
114
|
+
// ids 若被写成单个字符串则包成数组(validateDecisions 要求数组)。
|
|
115
|
+
if (d.ids !== undefined && typeof d.ids === "string") d.ids = [d.ids];
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 归一化 LLM 决策 body(数组 / 包裹对象 / 单个决策对象),null 原样返回。 */
|
|
120
|
+
function normalizeDecisions(raw) {
|
|
121
|
+
if (!raw) return raw;
|
|
122
|
+
if (Array.isArray(raw)) return raw.map(normalizeOne).filter((d) => d && typeof d === "object");
|
|
123
|
+
if (typeof raw === "object") {
|
|
124
|
+
for (const key of WRAPPER_KEYS) {
|
|
125
|
+
if (Array.isArray(raw[key])) return normalizeDecisions(raw[key]);
|
|
126
|
+
}
|
|
127
|
+
// 单个决策对象 → 包成单元素数组(validateDecisions 要求数组)。
|
|
128
|
+
return [normalizeOne(raw)];
|
|
129
|
+
}
|
|
130
|
+
return raw;
|
|
131
|
+
}
|
|
43
132
|
const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
|
|
44
133
|
|
|
45
134
|
const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
|
|
@@ -616,7 +705,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
616
705
|
return finish({ ok: false, error: "llm failed", summary: false });
|
|
617
706
|
}
|
|
618
707
|
|
|
619
|
-
|
|
708
|
+
// v0.5.3 字段名归一化兜底:thinking 模型可能输出别名键/wrapper 对象,
|
|
709
|
+
// 在 validateDecisions 之前重写到规范字段名,语义正确但 schema 不听话的
|
|
710
|
+
// 输出不再整单被拒。
|
|
711
|
+
const decisions = normalizeDecisions(extractJsonArray(decisionText));
|
|
620
712
|
if (!Array.isArray(decisions)) {
|
|
621
713
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0})`);
|
|
622
714
|
return finish({ ok: false, error: "no json array in llm output", summary: false });
|
package/lib/service.js
CHANGED
|
@@ -811,6 +811,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
811
811
|
tags: memory.tags ?? [],
|
|
812
812
|
importance: memory.importance ?? 3,
|
|
813
813
|
source: memory.source ?? "manual",
|
|
814
|
+
// Provenance (v0.5.x): birth session rides through the create path; the
|
|
815
|
+
// merge path above preserves the original row's session_id untouched.
|
|
816
|
+
session_id: memory.session_id ?? undefined,
|
|
814
817
|
...(quality ? { quality_score: quality.score } : {})
|
|
815
818
|
});
|
|
816
819
|
const result = applyQualityDisposition(created, quality, qf);
|
package/lib/store.js
CHANGED
|
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
12
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
14
|
source TEXT,
|
|
15
|
+
session_id TEXT,
|
|
15
16
|
content_history TEXT,
|
|
16
17
|
embedding TEXT,
|
|
17
18
|
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
@@ -321,6 +322,7 @@ function toRow(row) {
|
|
|
321
322
|
forgotten: row.forgotten === 1,
|
|
322
323
|
archived: row.archived === 1,
|
|
323
324
|
source: row.source ?? undefined,
|
|
325
|
+
session_id: row.session_id ?? undefined,
|
|
324
326
|
content_history: parseJsonArray(row.content_history),
|
|
325
327
|
quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
|
|
326
328
|
epistemic_status: row.epistemic_status ?? "subjective",
|
|
@@ -571,6 +573,7 @@ export function createStore(path) {
|
|
|
571
573
|
addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
|
|
572
574
|
addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
|
|
573
575
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
576
|
+
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
574
577
|
|
|
575
578
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
576
579
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
@@ -657,8 +660,8 @@ export function createStore(path) {
|
|
|
657
660
|
: inferEpistemicStatus(memory);
|
|
658
661
|
runAtomically(() => {
|
|
659
662
|
db.prepare(
|
|
660
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
661
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
663
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, session_id, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
664
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
662
665
|
).run(
|
|
663
666
|
id,
|
|
664
667
|
type,
|
|
@@ -668,6 +671,7 @@ export function createStore(path) {
|
|
|
668
671
|
importance,
|
|
669
672
|
memory.archived ? 1 : 0,
|
|
670
673
|
memory.source ?? null,
|
|
674
|
+
memory.session_id ?? null,
|
|
671
675
|
JSON.stringify(memory.content_history ?? []),
|
|
672
676
|
Number.isFinite(memory.quality_score) ? memory.quality_score : null,
|
|
673
677
|
embedding,
|
package/lib/summarize.js
CHANGED
|
@@ -182,7 +182,11 @@ export function createSummarizer(ctx, service, config) {
|
|
|
182
182
|
.join("");
|
|
183
183
|
const entries = parseSummaryJson(text || assembledText);
|
|
184
184
|
for (const entry of entries) {
|
|
185
|
-
|
|
185
|
+
// Provenance: the summarizer runs on a real session (turn/end hook), so
|
|
186
|
+
// session.id is always available here — it rides both the human-readable
|
|
187
|
+
// source label and the structured session_id column (v0.5.x memory
|
|
188
|
+
// provenance, the raw material for v0.6.0 reasoning-path / drift analysis).
|
|
189
|
+
service.saveWithDedupe({ ...entry, source: `session:${session.id}`, session_id: session.id });
|
|
186
190
|
}
|
|
187
191
|
} finally {
|
|
188
192
|
if (audit) {
|
package/lib/tools.js
CHANGED
|
@@ -16,6 +16,7 @@ const MEMORY_ITEM_SCHEMA = {
|
|
|
16
16
|
tags: { type: "array", items: { type: "string" } },
|
|
17
17
|
importance: { type: "integer", required: true },
|
|
18
18
|
source: { type: "string" },
|
|
19
|
+
session_id: { type: "string" },
|
|
19
20
|
created_at: { type: "string", required: true },
|
|
20
21
|
updated_at: { type: "string", required: true }
|
|
21
22
|
}
|
|
@@ -48,14 +49,18 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
48
49
|
},
|
|
49
50
|
render: (_args, value) => TEXT_OUTPUT(`memory ${value.action}: ${value.id}`)
|
|
50
51
|
},
|
|
51
|
-
async execute(args) {
|
|
52
|
+
async execute(args, exec) {
|
|
52
53
|
const { action, memory } = service.saveWithDedupe({
|
|
53
54
|
type: args.type,
|
|
54
55
|
title: args.title,
|
|
55
56
|
content: args.content,
|
|
56
57
|
tags: args.tags ?? [],
|
|
57
58
|
importance: args.importance ?? 3,
|
|
58
|
-
source: args.source ?? "tool"
|
|
59
|
+
source: args.source ?? "tool",
|
|
60
|
+
// Provenance: the session that issued the tool call. exec.agent is
|
|
61
|
+
// set by the agent loop (undefined in unit tests / direct calls) —
|
|
62
|
+
// absent a session, session_id stays null rather than fabricating one.
|
|
63
|
+
session_id: exec?.agent?.session?.id ?? undefined
|
|
59
64
|
});
|
|
60
65
|
return { action, id: memory.id };
|
|
61
66
|
}
|
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, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.3",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/config.js
CHANGED
|
@@ -19,11 +19,13 @@ export const Config = z.object({
|
|
|
19
19
|
dreamModel: z.string(),
|
|
20
20
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
21
21
|
// Pass-through reasoning effort for dream's LLM calls. 'none' (default)
|
|
22
|
-
// omits the field so the provider's own default applies;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// empty body ("no json array in llm output")
|
|
22
|
+
// omits the field so the provider's own default applies; 'off' explicitly
|
|
23
|
+
// disables thinking — REQUIRED for thinking-type models (deepseek-v4-flash
|
|
24
|
+
// etc.) that would otherwise drain the whole token budget into reasoning and
|
|
25
|
+
// return an empty body ("no json array in llm output"); low/medium/high are
|
|
26
|
+
// forwarded verbatim to cap reasoning spend.
|
|
26
27
|
dreamReasoningEffort: z.union([
|
|
28
|
+
z.const("off"),
|
|
27
29
|
z.const("low"),
|
|
28
30
|
z.const("medium"),
|
|
29
31
|
z.const("high"),
|
|
@@ -197,9 +199,11 @@ export const Config = z.object({
|
|
|
197
199
|
sleepProvider: z.string().default(""),
|
|
198
200
|
sleepModel: z.string().default(""),
|
|
199
201
|
// Pass-through reasoning effort for sleep's LLM passes, same semantics as
|
|
200
|
-
// dreamReasoningEffort: 'none' (default) omits the field;
|
|
201
|
-
//
|
|
202
|
+
// dreamReasoningEffort: 'none' (default) omits the field; 'off' explicitly
|
|
203
|
+
// disables thinking (thinking-type models would burn the whole budget on
|
|
204
|
+
// reasoning); low/medium/high are forwarded verbatim.
|
|
202
205
|
sleepReasoningEffort: z.union([
|
|
206
|
+
z.const("off"),
|
|
203
207
|
z.const("low"),
|
|
204
208
|
z.const("medium"),
|
|
205
209
|
z.const("high"),
|
package/src/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions };
|
|
4
|
+
export { validateDecisions, applyDecisions, normalizeDecisions };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -40,6 +40,95 @@ function extractJsonArray(text) {
|
|
|
40
40
|
}
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Field-name drift guard (v0.5.3): thinking-type models (deepseek-v4-flash
|
|
46
|
+
* etc.) occasionally ignore the prompt's exact decision schema and emit alias
|
|
47
|
+
* keys —实测方案 A 输出 "consolidation"/"target_ids"、方案 B 输出
|
|
48
|
+
* "action"/"targetIds",都不是插件要求的 "action"/"ids"。normalizeDecisions
|
|
49
|
+
* 在 validateDecisions 之前把常见变体重写回规范字段名,让"字段名不听话但
|
|
50
|
+
* 语义正确"的输出仍可被应用,而不是整单被拒。覆盖三类漂移:
|
|
51
|
+
* 1. 整个 body 是包在对象里的数组({consolidation:[...]} /
|
|
52
|
+
* {decisions:[...]} / {actions:[...]});
|
|
53
|
+
* 2. 决策对象用了别名键(target_ids→ids、keep_source→keepSource、
|
|
54
|
+
* winner_id→winner、targetIds→ids 等);
|
|
55
|
+
* 3. action 值用了同义词(archived→archive、consolidation→merge 等)。
|
|
56
|
+
* 无归一化必要时原样返回(null→null,调用方"no json array"分支不受影响)。
|
|
57
|
+
*/
|
|
58
|
+
const FIELD_ALIASES = {
|
|
59
|
+
// "consolidation" 也可作 action 键(实测 deepseek-v4-flash 这么写过);
|
|
60
|
+
// 但 normalizeOne 对 action 键做字符串过滤,{consolidation:[...]} 这种
|
|
61
|
+
// wrapper 数组不会被误当成 action(顶层 WRAPPER_KEYS 负责解包)。
|
|
62
|
+
action: ["action", "decision", "operation", "op", "action_type", "actionType", "mode", "consolidation"],
|
|
63
|
+
// 注意:action 别名不含 "type"——create 决策的 type 是合法的记忆类型字段,
|
|
64
|
+
// 不能误当 action。
|
|
65
|
+
ids: ["ids", "target_ids", "targetIds", "targets", "memory_ids", "memoryIds", "id_list", "idList", "memories"],
|
|
66
|
+
reason: ["reason", "rationale", "why", "comment", "note", "explanation"],
|
|
67
|
+
importance: ["importance", "priority", "weight", "level", "score"],
|
|
68
|
+
keepSource: ["keepSource", "keep_source", "source_id", "sourceId", "keeper", "keep_id", "keepId"],
|
|
69
|
+
winner: ["winner", "winner_id", "winnerId", "win_id", "winId", "preferred", "primary"],
|
|
70
|
+
loser: ["loser", "loser_id", "loserId", "lose_id", "loseId", "drop_id", "dropId", "archive_id", "archiveId"],
|
|
71
|
+
title: ["title", "new_title", "newTitle", "merged_title", "mergedTitle", "merge_title", "mergeTitle"],
|
|
72
|
+
content: ["content", "new_content", "newContent", "merged_content", "mergedContent", "merge_content", "mergeContent"],
|
|
73
|
+
evidence: ["evidence", "evidence_ids", "evidenceIds"]
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 保守的 action 值同义词:只收语义无歧义、不可能被误认为记忆类型/其他 action 的映射。
|
|
77
|
+
const ACTION_SYNONYMS = {
|
|
78
|
+
archived: "archive",
|
|
79
|
+
remove: "archive",
|
|
80
|
+
delete: "archive",
|
|
81
|
+
combine: "merge",
|
|
82
|
+
consolidation: "merge",
|
|
83
|
+
consolidate: "merge",
|
|
84
|
+
modify: "update",
|
|
85
|
+
edit: "update"
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const WRAPPER_KEYS = ["consolidation", "decisions", "actions", "results", "updates", "list", "data"];
|
|
89
|
+
|
|
90
|
+
/** 把单个决策对象重写到规范字段名;非对象原样返回。 */
|
|
91
|
+
function normalizeOne(raw) {
|
|
92
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
93
|
+
const d = {};
|
|
94
|
+
const consumed = new Set();
|
|
95
|
+
for (const [canon, aliases] of Object.entries(FIELD_ALIASES)) {
|
|
96
|
+
for (const key of aliases) {
|
|
97
|
+
if (key in raw && raw[key] !== undefined && raw[key] !== null) {
|
|
98
|
+
// action 必须是字符串——{consolidation:[...]} 的 wrapper 数组跳过。
|
|
99
|
+
if (canon === "action" && Array.isArray(raw[key])) continue;
|
|
100
|
+
d[canon] = raw[key];
|
|
101
|
+
consumed.add(key);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 保留未被别名消费的原始键(create 的 type、未来字段)原样透传。
|
|
107
|
+
for (const key of Object.keys(raw)) {
|
|
108
|
+
if (!consumed.has(key)) d[key] = raw[key];
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.action === "string") {
|
|
111
|
+
const low = d.action.toLowerCase();
|
|
112
|
+
if (ACTION_SYNONYMS[low] !== undefined) d.action = ACTION_SYNONYMS[low];
|
|
113
|
+
}
|
|
114
|
+
// ids 若被写成单个字符串则包成数组(validateDecisions 要求数组)。
|
|
115
|
+
if (d.ids !== undefined && typeof d.ids === "string") d.ids = [d.ids];
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 归一化 LLM 决策 body(数组 / 包裹对象 / 单个决策对象),null 原样返回。 */
|
|
120
|
+
function normalizeDecisions(raw) {
|
|
121
|
+
if (!raw) return raw;
|
|
122
|
+
if (Array.isArray(raw)) return raw.map(normalizeOne).filter((d) => d && typeof d === "object");
|
|
123
|
+
if (typeof raw === "object") {
|
|
124
|
+
for (const key of WRAPPER_KEYS) {
|
|
125
|
+
if (Array.isArray(raw[key])) return normalizeDecisions(raw[key]);
|
|
126
|
+
}
|
|
127
|
+
// 单个决策对象 → 包成单元素数组(validateDecisions 要求数组)。
|
|
128
|
+
return [normalizeOne(raw)];
|
|
129
|
+
}
|
|
130
|
+
return raw;
|
|
131
|
+
}
|
|
43
132
|
const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
|
|
44
133
|
|
|
45
134
|
const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
|
|
@@ -616,7 +705,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
616
705
|
return finish({ ok: false, error: "llm failed", summary: false });
|
|
617
706
|
}
|
|
618
707
|
|
|
619
|
-
|
|
708
|
+
// v0.5.3 字段名归一化兜底:thinking 模型可能输出别名键/wrapper 对象,
|
|
709
|
+
// 在 validateDecisions 之前重写到规范字段名,语义正确但 schema 不听话的
|
|
710
|
+
// 输出不再整单被拒。
|
|
711
|
+
const decisions = normalizeDecisions(extractJsonArray(decisionText));
|
|
620
712
|
if (!Array.isArray(decisions)) {
|
|
621
713
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0})`);
|
|
622
714
|
return finish({ ok: false, error: "no json array in llm output", summary: false });
|
package/src/service.js
CHANGED
|
@@ -811,6 +811,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
811
811
|
tags: memory.tags ?? [],
|
|
812
812
|
importance: memory.importance ?? 3,
|
|
813
813
|
source: memory.source ?? "manual",
|
|
814
|
+
// Provenance (v0.5.x): birth session rides through the create path; the
|
|
815
|
+
// merge path above preserves the original row's session_id untouched.
|
|
816
|
+
session_id: memory.session_id ?? undefined,
|
|
814
817
|
...(quality ? { quality_score: quality.score } : {})
|
|
815
818
|
});
|
|
816
819
|
const result = applyQualityDisposition(created, quality, qf);
|
package/src/store.js
CHANGED
|
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
12
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
14
|
source TEXT,
|
|
15
|
+
session_id TEXT,
|
|
15
16
|
content_history TEXT,
|
|
16
17
|
embedding TEXT,
|
|
17
18
|
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
@@ -321,6 +322,7 @@ function toRow(row) {
|
|
|
321
322
|
forgotten: row.forgotten === 1,
|
|
322
323
|
archived: row.archived === 1,
|
|
323
324
|
source: row.source ?? undefined,
|
|
325
|
+
session_id: row.session_id ?? undefined,
|
|
324
326
|
content_history: parseJsonArray(row.content_history),
|
|
325
327
|
quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
|
|
326
328
|
epistemic_status: row.epistemic_status ?? "subjective",
|
|
@@ -571,6 +573,7 @@ export function createStore(path) {
|
|
|
571
573
|
addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
|
|
572
574
|
addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
|
|
573
575
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
576
|
+
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
574
577
|
|
|
575
578
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
576
579
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
@@ -657,8 +660,8 @@ export function createStore(path) {
|
|
|
657
660
|
: inferEpistemicStatus(memory);
|
|
658
661
|
runAtomically(() => {
|
|
659
662
|
db.prepare(
|
|
660
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
661
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
663
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, session_id, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
664
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
662
665
|
).run(
|
|
663
666
|
id,
|
|
664
667
|
type,
|
|
@@ -668,6 +671,7 @@ export function createStore(path) {
|
|
|
668
671
|
importance,
|
|
669
672
|
memory.archived ? 1 : 0,
|
|
670
673
|
memory.source ?? null,
|
|
674
|
+
memory.session_id ?? null,
|
|
671
675
|
JSON.stringify(memory.content_history ?? []),
|
|
672
676
|
Number.isFinite(memory.quality_score) ? memory.quality_score : null,
|
|
673
677
|
embedding,
|
package/src/summarize.js
CHANGED
|
@@ -182,7 +182,11 @@ export function createSummarizer(ctx, service, config) {
|
|
|
182
182
|
.join("");
|
|
183
183
|
const entries = parseSummaryJson(text || assembledText);
|
|
184
184
|
for (const entry of entries) {
|
|
185
|
-
|
|
185
|
+
// Provenance: the summarizer runs on a real session (turn/end hook), so
|
|
186
|
+
// session.id is always available here — it rides both the human-readable
|
|
187
|
+
// source label and the structured session_id column (v0.5.x memory
|
|
188
|
+
// provenance, the raw material for v0.6.0 reasoning-path / drift analysis).
|
|
189
|
+
service.saveWithDedupe({ ...entry, source: `session:${session.id}`, session_id: session.id });
|
|
186
190
|
}
|
|
187
191
|
} finally {
|
|
188
192
|
if (audit) {
|
package/src/tools.js
CHANGED
|
@@ -16,6 +16,7 @@ const MEMORY_ITEM_SCHEMA = {
|
|
|
16
16
|
tags: { type: "array", items: { type: "string" } },
|
|
17
17
|
importance: { type: "integer", required: true },
|
|
18
18
|
source: { type: "string" },
|
|
19
|
+
session_id: { type: "string" },
|
|
19
20
|
created_at: { type: "string", required: true },
|
|
20
21
|
updated_at: { type: "string", required: true }
|
|
21
22
|
}
|
|
@@ -48,14 +49,18 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
48
49
|
},
|
|
49
50
|
render: (_args, value) => TEXT_OUTPUT(`memory ${value.action}: ${value.id}`)
|
|
50
51
|
},
|
|
51
|
-
async execute(args) {
|
|
52
|
+
async execute(args, exec) {
|
|
52
53
|
const { action, memory } = service.saveWithDedupe({
|
|
53
54
|
type: args.type,
|
|
54
55
|
title: args.title,
|
|
55
56
|
content: args.content,
|
|
56
57
|
tags: args.tags ?? [],
|
|
57
58
|
importance: args.importance ?? 3,
|
|
58
|
-
source: args.source ?? "tool"
|
|
59
|
+
source: args.source ?? "tool",
|
|
60
|
+
// Provenance: the session that issued the tool call. exec.agent is
|
|
61
|
+
// set by the agent loop (undefined in unit tests / direct calls) —
|
|
62
|
+
// absent a session, session_id stays null rather than fabricating one.
|
|
63
|
+
session_id: exec?.agent?.session?.id ?? undefined
|
|
59
64
|
});
|
|
60
65
|
return { action, id: memory.id };
|
|
61
66
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Field-name normalization guard (v0.5.3): thinking-type models
|
|
2
|
+
// (deepseek-v4-flash etc.) sometimes ignore the prompt's exact decision schema
|
|
3
|
+
// and emit alias keys —实测方案 A 输出 "consolidation"/"target_ids"、方案 B
|
|
4
|
+
// 输出 "action"/"targetIds",都不是插件要求的 "action"/"ids"。normalizeDecisions
|
|
5
|
+
// 在 validateDecisions 之前把这些变体重写回规范字段名,让"语义正确但 schema
|
|
6
|
+
// 不听话"的输出仍被应用,而不是整单被拒。
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { normalizeDecisions, createDreamScheduler } from "../src/dream.js";
|
|
10
|
+
import { createStore } from "../src/store.js";
|
|
11
|
+
import { createService } from "../src/service.js";
|
|
12
|
+
|
|
13
|
+
test("normalizeDecisions: canonical decisions pass through untouched", () => {
|
|
14
|
+
const raw = [
|
|
15
|
+
{ action: "merge", ids: ["m1", "m2"], keepSource: "m1", title: "t", content: "c", importance: 4, reason: "主题相近" },
|
|
16
|
+
{ action: "conflict", winner: "m3", loser: "m4", reason: "矛盾" },
|
|
17
|
+
{ action: "update", ids: ["m5"], content: "修正" },
|
|
18
|
+
{ action: "archive", ids: ["m6"], reason: "过时" },
|
|
19
|
+
{ action: "create", type: "pattern", title: "新模式", content: "发现", importance: 3 }
|
|
20
|
+
];
|
|
21
|
+
assert.deepEqual(normalizeDecisions(raw), raw);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("normalizeDecisions: alias keys rewritten to canonical names", () => {
|
|
25
|
+
const raw = [
|
|
26
|
+
{ consolidation: "merge", target_ids: ["m1", "m2"], keep_source: "m1", new_title: "t", new_content: "c", priority: 4, rationale: "主题相近" },
|
|
27
|
+
{ action: "conflict", winner_id: "m3", loser_id: "m4", why: "矛盾" },
|
|
28
|
+
{ action: "update", targetIds: ["m5"], merged_content: "修正" },
|
|
29
|
+
{ action: "archive", memory_ids: ["m6"] }
|
|
30
|
+
];
|
|
31
|
+
assert.deepEqual(normalizeDecisions(raw), [
|
|
32
|
+
{ action: "merge", ids: ["m1", "m2"], keepSource: "m1", title: "t", content: "c", importance: 4, reason: "主题相近" },
|
|
33
|
+
{ action: "conflict", winner: "m3", loser: "m4", reason: "矛盾" },
|
|
34
|
+
{ action: "update", ids: ["m5"], content: "修正" },
|
|
35
|
+
{ action: "archive", ids: ["m6"] }
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("normalizeDecisions: action value synonyms normalized", () => {
|
|
40
|
+
const raw = [
|
|
41
|
+
{ action: "archived", ids: ["m1"], reason: "重复" },
|
|
42
|
+
{ action: "consolidation", target_ids: ["m2", "m3"], keep_source: "m2" },
|
|
43
|
+
{ action: "combine", targetIds: ["m4", "m5"], keeper: "m5" },
|
|
44
|
+
{ action: "Remove", memory_ids: ["m6"] }
|
|
45
|
+
];
|
|
46
|
+
const normalized = normalizeDecisions(raw);
|
|
47
|
+
assert.equal(normalized[0].action, "archive");
|
|
48
|
+
assert.equal(normalized[1].action, "merge");
|
|
49
|
+
assert.equal(normalized[1].ids[0], "m2");
|
|
50
|
+
assert.equal(normalized[2].action, "merge");
|
|
51
|
+
assert.equal(normalized[2].keepSource, "m5");
|
|
52
|
+
assert.equal(normalized[3].action, "archive");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("normalizeDecisions: wrapper object around the array is unwrapped", () => {
|
|
56
|
+
const raw = { consolidation: [{ action: "merge", ids: ["m1"], keepSource: "m1", title: "t", content: "c" }] };
|
|
57
|
+
assert.deepEqual(normalizeDecisions(raw), [
|
|
58
|
+
{ action: "merge", ids: ["m1"], keepSource: "m1", title: "t", content: "c" }
|
|
59
|
+
]);
|
|
60
|
+
const raw2 = { decisions: [{ action: "archive", target_ids: ["m9"] }] };
|
|
61
|
+
assert.deepEqual(normalizeDecisions(raw2), [{ action: "archive", ids: ["m9"] }]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("normalizeDecisions: single decision object (non-array) normalized too", () => {
|
|
65
|
+
const raw = { action: "update", targetIds: ["m1"], new_content: "修正" };
|
|
66
|
+
assert.deepEqual(normalizeDecisions(raw), [{ action: "update", ids: ["m1"], content: "修正" }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("normalizeDecisions: single-string ids become a one-element array", () => {
|
|
70
|
+
const raw = [{ action: "archive", target_id: "m7" }];
|
|
71
|
+
// target_id (singular) is not an alias key — the raw key survives untouched
|
|
72
|
+
// and validateDecisions rejects it (safe side). But targetIds (plural, string)
|
|
73
|
+
// IS mapped and wrapped.
|
|
74
|
+
const raw2 = [{ action: "archive", targetIds: "m7" }];
|
|
75
|
+
assert.deepEqual(normalizeDecisions(raw2), [{ action: "archive", ids: ["m7"] }]);
|
|
76
|
+
assert.deepEqual(normalizeDecisions(raw), [{ action: "archive", target_id: "m7" }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("normalizeDecisions: create keeps its type field (never mistaken for action)", () => {
|
|
80
|
+
const raw = { action: "create", type: "preference", title: "语言", content: "中文", importance: 3 };
|
|
81
|
+
assert.deepEqual(normalizeDecisions(raw), [
|
|
82
|
+
{ action: "create", type: "preference", title: "语言", content: "中文", importance: 3 }
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("normalizeDecisions: null / non-parseable returns as-is (caller's no-json branch)", () => {
|
|
87
|
+
assert.equal(normalizeDecisions(null), null);
|
|
88
|
+
assert.equal(normalizeDecisions(undefined), undefined);
|
|
89
|
+
assert.equal(normalizeDecisions("not json"), "not json");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("dream runDream applies alias-key consolidation output end-to-end", async () => {
|
|
93
|
+
const store = createStore(":memory:");
|
|
94
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
95
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
96
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
97
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
98
|
+
const ctx = {
|
|
99
|
+
logger: { warn: () => {} },
|
|
100
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
101
|
+
llm: {
|
|
102
|
+
async *stream(options) {
|
|
103
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
104
|
+
if (userText.startsWith("id=")) {
|
|
105
|
+
// deepseek-v4-flash 实测风格:consolidation 当 action、target_ids 当 ids
|
|
106
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
107
|
+
{ consolidation: "merge", target_ids: [a.id, b.id], keep_source: b.id, new_title: "插件总览", new_content: "合并内容", priority: 4 }
|
|
108
|
+
]) };
|
|
109
|
+
} else {
|
|
110
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览:默认摘要。" };
|
|
111
|
+
}
|
|
112
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const result = await dream.runDream(ctx, service, {});
|
|
117
|
+
assert.equal(result.ok, true, "alias-key output must not be rejected wholesale");
|
|
118
|
+
assert.ok(result.applied > 0, "alias-key decisions still land changes");
|
|
119
|
+
store.close();
|
|
120
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createStore } from "../src/store.js";
|
|
8
|
+
import { createService } from "../src/service.js";
|
|
9
|
+
import { createTools } from "../src/tools.js";
|
|
10
|
+
|
|
11
|
+
const openMemory = () => createStore(":memory:");
|
|
12
|
+
|
|
13
|
+
// Memory provenance (v0.5.x): every memory born from a session carries its
|
|
14
|
+
// session_id so v0.6.0 reasoning-path / interest-drift analysis can rebuild
|
|
15
|
+
// the derivation chain later. session_id is birth provenance — set once at
|
|
16
|
+
// creation, preserved by merges, never fabricated when no session exists.
|
|
17
|
+
|
|
18
|
+
test("save persists session_id and returns it", () => {
|
|
19
|
+
const store = openMemory();
|
|
20
|
+
const saved = store.save({
|
|
21
|
+
type: "preference",
|
|
22
|
+
title: "A",
|
|
23
|
+
content: "c",
|
|
24
|
+
tags: [],
|
|
25
|
+
importance: 3,
|
|
26
|
+
source: "tool",
|
|
27
|
+
session_id: "session-42"
|
|
28
|
+
});
|
|
29
|
+
assert.equal(saved.session_id, "session-42");
|
|
30
|
+
assert.equal(store.getById(saved.id).session_id, "session-42");
|
|
31
|
+
store.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("save without session_id leaves it undefined (never fabricated)", () => {
|
|
35
|
+
const store = openMemory();
|
|
36
|
+
const saved = store.save({ type: "history", title: "B", content: "c" });
|
|
37
|
+
assert.equal(saved.session_id, undefined);
|
|
38
|
+
store.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dedupe merge keeps the ORIGINAL session_id (provenance = birth)", () => {
|
|
42
|
+
const store = openMemory();
|
|
43
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
44
|
+
service.saveWithDedupe({
|
|
45
|
+
type: "preference", title: "语言", content: "中文", session_id: "session-1"
|
|
46
|
+
});
|
|
47
|
+
const merged = service.saveWithDedupe({
|
|
48
|
+
type: "preference", title: "语言", content: "中文 + 英文", session_id: "session-2"
|
|
49
|
+
});
|
|
50
|
+
assert.equal(merged.action, "merged");
|
|
51
|
+
assert.equal(store.getById(merged.memory.id).session_id, "session-1");
|
|
52
|
+
store.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("memory_save tool records exec.agent.session.id as session_id", async () => {
|
|
56
|
+
const store = openMemory();
|
|
57
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
58
|
+
const ctx = { tools: { register() { return () => {}; } } };
|
|
59
|
+
const tools = createTools(ctx, service, {}, undefined);
|
|
60
|
+
const saveTool = tools.find((t) => t.name === "memory_save");
|
|
61
|
+
const out = await saveTool.execute(
|
|
62
|
+
{ type: "decision", title: "T", content: "c" },
|
|
63
|
+
{ agent: { session: { id: "session-tool-9" } } }
|
|
64
|
+
);
|
|
65
|
+
assert.equal(store.getById(out.id).session_id, "session-tool-9");
|
|
66
|
+
store.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("memory_save tool without agent context leaves session_id null", async () => {
|
|
70
|
+
const store = openMemory();
|
|
71
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
72
|
+
const ctx = { tools: { register() { return () => {}; } } };
|
|
73
|
+
const tools = createTools(ctx, service, {}, undefined);
|
|
74
|
+
const saveTool = tools.find((t) => t.name === "memory_save");
|
|
75
|
+
const out = await saveTool.execute({ type: "decision", title: "T2", content: "c" }, {});
|
|
76
|
+
assert.equal(store.getById(out.id).session_id, undefined);
|
|
77
|
+
store.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("legacy DB without session_id column migrates and saves with provenance", () => {
|
|
81
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-prov-"));
|
|
82
|
+
const dbPath = join(dir, "legacy.db");
|
|
83
|
+
const db = new DatabaseSync(dbPath);
|
|
84
|
+
db.exec(`CREATE TABLE memories (
|
|
85
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
86
|
+
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
87
|
+
forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
|
|
88
|
+
source TEXT, content_history TEXT, embedding TEXT,
|
|
89
|
+
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
90
|
+
last_accessed_at TEXT, _full_content TEXT,
|
|
91
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
92
|
+
);`);
|
|
93
|
+
db.close();
|
|
94
|
+
const store = createStore(dbPath);
|
|
95
|
+
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
96
|
+
assert.ok(cols.includes("session_id"), "session_id column added by migration");
|
|
97
|
+
const saved = store.save({
|
|
98
|
+
type: "history", title: "旧库", content: "迁移后仍可溯源", session_id: "session-legacy"
|
|
99
|
+
});
|
|
100
|
+
assert.equal(store.getById(saved.id).session_id, "session-legacy");
|
|
101
|
+
store.close();
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
@@ -36,6 +36,11 @@ test("issue#9: reasoningEffort config defaults to none and rejects unknown value
|
|
|
36
36
|
assert.equal(cfg.sleepReasoningEffort, "none");
|
|
37
37
|
assert.equal(Config({ dreamReasoningEffort: "high" }).dreamReasoningEffort, "high");
|
|
38
38
|
assert.equal(Config({ sleepReasoningEffort: "medium" }).sleepReasoningEffort, "medium");
|
|
39
|
+
// v0.5.3: 'off' explicitly disables thinking — required for thinking-type
|
|
40
|
+
// models (deepseek-v4-flash) whose reasoning otherwise drains the whole
|
|
41
|
+
// token budget and returns an empty consolidation body.
|
|
42
|
+
assert.equal(Config({ dreamReasoningEffort: "off" }).dreamReasoningEffort, "off");
|
|
43
|
+
assert.equal(Config({ sleepReasoningEffort: "off" }).sleepReasoningEffort, "off");
|
|
39
44
|
assert.throws(() => Config({ dreamReasoningEffort: "bogus" }), "invalid effort rejected");
|
|
40
45
|
assert.throws(() => Config({ sleepReasoningEffort: "ultra" }), "invalid effort rejected");
|
|
41
46
|
});
|
|
@@ -107,6 +112,28 @@ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async ()
|
|
|
107
112
|
store.close();
|
|
108
113
|
});
|
|
109
114
|
|
|
115
|
+
test("issue#9: dream forwards dreamReasoningEffort:off (thinking disabled) on both LLM calls", async () => {
|
|
116
|
+
const store = createStore(":memory:");
|
|
117
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
118
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
119
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
120
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
121
|
+
const captured = [];
|
|
122
|
+
const ctx = dreamCtx({
|
|
123
|
+
captured,
|
|
124
|
+
onConsolidation: () => JSON.stringify([
|
|
125
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
|
|
126
|
+
])
|
|
127
|
+
});
|
|
128
|
+
const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "off" });
|
|
129
|
+
assert.equal(result.ok, true);
|
|
130
|
+
assert.equal(captured.length, 2);
|
|
131
|
+
for (const options of captured) {
|
|
132
|
+
assert.equal(options.reasoningEffort, "off", `reasoningEffort:"off" forwarded on ${options.purpose}`);
|
|
133
|
+
}
|
|
134
|
+
store.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
110
137
|
// ---------------------------------------------------------------- sleep passthrough
|
|
111
138
|
|
|
112
139
|
function sleepSetup() {
|