@modusensus/dsh-mneme 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Modusensus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # dsh-mneme
2
+
3
+ > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。
4
+
5
+ `dsh-mneme` 是一个 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 插件,为 Agent 提供持久的跨会话记忆能力。它借鉴了 Claude 的 **Dream 机制** 与 cc-haha / Claude Code 的 **autoDream** 实现思路——不仅**存储**记忆,还会**自动巩固**(去重、合并、冲突裁决、摘要生成),让记忆库越用越精炼。
6
+
7
+ ## ✨ 功能
8
+
9
+ ### 记忆存储(SQLite + Markdown 镜像)
10
+
11
+ - **SQLite 主存储**:`~/.dsh/memory/memory.db`,`node:sqlite` 内置,零原生依赖
12
+ - **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
13
+ - **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
14
+
15
+ ### 模型工具(6 个)
16
+
17
+ | 工具 | 功能 |
18
+ |------|------|
19
+ | `memory_save` | 记录一条记忆(自动按标题去重合并) |
20
+ | `memory_search` | 全文搜索(中文子串友好) |
21
+ | `memory_list` | 按类型分页列出 |
22
+ | `memory_update` | 修改已有记忆 |
23
+ | `memory_delete` | 删除记忆 |
24
+ | `memory_forget` | 抑制注入(降权不删除,可恢复) |
25
+
26
+ ### 自动注入 + 会话摘要
27
+
28
+ - **自动注入**:新会话开局注入记忆摘要(`summary` 优先 + 少量高重要性条目)
29
+ - **会话摘要**:`turn/end` 时用 LLM 提炼本次会话的偏好/决策/教训,自动入库(过滤 plugin 注入上下文,避免污染)
30
+
31
+ ### autoDream 自动记忆整理 🧠
32
+
33
+ - **触发**:记忆数 > 10 或总字符 > 5000 时,异步自动触发(不阻塞写入)
34
+ - **决策清单式整理**:LLM 输出 `keep` / `merge` / `archive` / `conflict` 决策清单,服务端校验后逐条应用
35
+ - `merge`:合并主题相近的条目,保留信息最完整者
36
+ - `archive`:归档过时/冗余条目(可恢复,不物理删除)
37
+ - `conflict`:裁决矛盾信息,胜者保留、败者归档并追加溯源注释
38
+ - **摘要生成**:整理后生成"记忆库总览"(单一实例),作为下次会话的优先注入
39
+ - **Fail-safe**:非法 LLM 输出(未知 id / 非法 action / 跨类型合并 / 越界 importance)拒绝整单,绝不破坏记忆库
40
+
41
+ ### Web 记忆面板
42
+
43
+ 侧边栏"记忆"按钮 → 模态面板:按类型浏览、全文搜索、查看详情。
44
+
45
+ ## 📦 安装
46
+
47
+ ### 前置条件
48
+
49
+ - [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)
50
+ - Node 24+(`node:sqlite`)
51
+
52
+ ### 安装步骤
53
+
54
+ #### 方式一:npm 安装(推荐)
55
+
56
+ ```bash
57
+ # 1. 在 DSH web profile 安装插件
58
+ dsh plugin --profile web add @modusensus/dsh-mneme
59
+
60
+ # 2. 在 ~/.dsh/profiles/web/cordis.patch.yml 注册插件(见下方配置块)
61
+ # 3. 重启
62
+ dsh web
63
+ ```
64
+
65
+ #### 方式二:从源码安装
66
+
67
+ ```bash
68
+ git clone https://github.com/modusensus/dsh-mneme.git
69
+ ```
70
+
71
+ 在 `~/.dsh/profiles/web/package.json` 添加依赖:
72
+
73
+ ```json
74
+ {
75
+ "dependencies": {
76
+ "@modusensus/dsh-mneme": "file:/path/to/dsh-mneme"
77
+ }
78
+ }
79
+ ```
80
+
81
+ #### 插件注册(`~/.dsh/profiles/web/cordis.patch.yml`)
82
+
83
+ ```yaml
84
+ - insert:
85
+ - id: dsh-mneme
86
+ name: '@modusensus/dsh-mneme'
87
+ config:
88
+ memoryDir: ~/.dsh/memory
89
+ autoInject: true
90
+ autoSummarize: true
91
+ maxInjectedItems: 5
92
+ importanceThreshold: 3
93
+ autoDream: true
94
+ dreamThresholdCount: 10
95
+ dreamThresholdChars: 5000
96
+ dreamDelayMs: 2000
97
+ ```
98
+
99
+ 最后安装依赖并重启:
100
+
101
+ ```bash
102
+ cd ~/.dsh/profiles/web
103
+ pnpm install
104
+ dsh web
105
+ ```
106
+
107
+ ## ⚙️ 配置
108
+
109
+ | 键 | 默认值 | 说明 |
110
+ |----|--------|------|
111
+ | `memoryDir` | `~/.dsh/memory` | 记忆存储目录(SQLite + Markdown) |
112
+ | `autoInject` | `true` | 会话启动自动注入记忆 |
113
+ | `autoSummarize` | `true` | 会话结束自动提炼摘要 |
114
+ | `maxInjectedItems` | `5` | 最多注入几条记忆 |
115
+ | `importanceThreshold` | `3` | 注入的最低重要性(1-5) |
116
+ | `autoDream` | `true` | 自动记忆整理开关 |
117
+ | `dreamThresholdCount` | `10` | 触发整理的记忆条数阈值 |
118
+ | `dreamThresholdChars` | `5000` | 触发整理的总字符阈值 |
119
+ | `dreamDelayMs` | `2000` | 整理异步延迟(去抖) |
120
+ | `dreamProvider` / `dreamModel` | 空 | dream 的 LLM 路由回退(默认用 agent 默认模型) |
121
+
122
+ ## 🏗️ 架构
123
+
124
+ ```
125
+ ┌─────────────────────────────────────────────────┐
126
+ │ 存储层:SQLite (archived/forgotten 状态) │
127
+ │ + Markdown 镜像(人工可编辑,双向同步) │
128
+ ├─────────────────────────────────────────────────┤
129
+ │ 服务层:saveWithDedupe / injectCandidates │
130
+ │ / mergeHumanEdits / onWrite 钩子 │
131
+ ├─────────────────────────────────────────────────┤
132
+ │ 模型接口:6 个工具 + 自动注入 + 会话摘要 │
133
+ ├─────────────────────────────────────────────────┤
134
+ │ autoDream:阈值调度 → LLM 决策清单 │
135
+ │ → 校验(fail-safe)→ 应用 → 摘要 │
136
+ ├─────────────────────────────────────────────────┤
137
+ │ Web 面板:侧边栏入口 + 浏览/搜索/详情 │
138
+ └─────────────────────────────────────────────────┘
139
+ ```
140
+
141
+ **源码结构**:
142
+
143
+ ```
144
+ src/
145
+ ├── store.js # SQLite 存储(CRUD、搜索、归档/遗忘、schema 迁移)
146
+ ├── mirror.js # Markdown 镜像(渲染/解析,人工优先)
147
+ ├── service.js # 领域逻辑(去重合并、注入筛选、写入钩子)
148
+ ├── config.js # schemastery 配置 schema
149
+ ├── tools.js # 6 个模型工具(defineTool)
150
+ ├── inject.js # systemPrompt.context 动态注入
151
+ ├── summarize.js # 会话结束 LLM 摘要
152
+ ├── dream.js # autoDream 调度 + runDream(LLM 决策 + 摘要)
153
+ ├── dream/decisions.js# 决策校验(fail-safe)+ 决策应用
154
+ ├── api.js # HTTP 路由(Web 面板数据通道)
155
+ └── index.js # 插件接线
156
+ lib/
157
+ ├── client.js # Web 面板(手写 ModuleLoader bundle)
158
+ └── *.js # src 的同步分发产物
159
+ test/ # 104 个 node:test 测试
160
+ ```
161
+
162
+ ## 🧪 开发
163
+
164
+ ```bash
165
+ cd dsh-mneme
166
+ node --test --test-isolation=none test/*.test.js
167
+ ```
168
+
169
+ > 注:`--test-isolation=none` 用于受限沙箱(禁止子进程 spawn);普通环境可直接 `node --test`。
170
+
171
+ ## 📄 设计文档
172
+
173
+ - [记忆库设计](docs/superpowers/specs/2026-08-13-dsh-memory-design.md)
174
+ - [autoDream 设计](docs/superpowers/specs/2026-08-13-dsh-memory-autodream-design.md)
175
+ - [实施计划](docs/superpowers/plans/2026-08-13-dsh-memory-autodream.md)
176
+
177
+ ## 📜 License
178
+
179
+ MIT
package/lib/api.js ADDED
@@ -0,0 +1,59 @@
1
+ import { URL } from "node:url";
2
+
3
+ function sendJson(res, status, payload) {
4
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
5
+ res.end(JSON.stringify(payload));
6
+ }
7
+
8
+ export function createApi(ctx, service) {
9
+ const disposers = [];
10
+
11
+ // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
12
+ disposers.push(ctx.webServer.register({
13
+ kind: "prefix",
14
+ path: "/api/dsh-mneme",
15
+ handler(req, res) {
16
+ sendJson(res, 404, { error: "not-found" });
17
+ }
18
+ }));
19
+
20
+ disposers.push(ctx.webServer.register({
21
+ kind: "exact",
22
+ path: "/api/dsh-mneme/list",
23
+ handler(req, res) {
24
+ try {
25
+ const url = new URL(req.url, "http://localhost");
26
+ const type = url.searchParams.get("type") ?? undefined;
27
+ const limit = Number(url.searchParams.get("limit") ?? 50);
28
+ const offset = Number(url.searchParams.get("offset") ?? 0);
29
+ const items = service.toApiList(service.list({ type, limit, offset }));
30
+ sendJson(res, 200, { items, total: service.count(type) });
31
+ } catch {
32
+ sendJson(res, 500, { error: "internal" });
33
+ }
34
+ }
35
+ }));
36
+
37
+ disposers.push(ctx.webServer.register({
38
+ kind: "exact",
39
+ path: "/api/dsh-mneme/search",
40
+ handler(req, res) {
41
+ try {
42
+ const url = new URL(req.url, "http://localhost");
43
+ const q = url.searchParams.get("q") ?? "";
44
+ const limit = Number(url.searchParams.get("limit") ?? 20);
45
+ const items = service.toApiList(service.search(q, { limit }));
46
+ sendJson(res, 200, { items });
47
+ } catch {
48
+ sendJson(res, 500, { error: "internal" });
49
+ }
50
+ }
51
+ }));
52
+
53
+ return {
54
+ routes: 3,
55
+ dispose: () => {
56
+ for (const dispose of disposers) dispose();
57
+ }
58
+ };
59
+ }
package/lib/client.js ADDED
@@ -0,0 +1,187 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-mneme",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+
7
+ let react = require("react");
8
+ let reactDom = require("react-dom");
9
+ let { useState, useEffect, useCallback, useRef } = react;
10
+ let { createPortal } = reactDom;
11
+
12
+ const inject = ["slots", "locale"];
13
+
14
+ const NS = "memory";
15
+
16
+ const dictionaries = {
17
+ zh: {
18
+ "memory.panel.title": "记忆库",
19
+ "memory.panel.search": "搜索记忆…",
20
+ "memory.panel.empty": "暂无记忆条目",
21
+ "memory.panel.open": "记忆",
22
+ "memory.tab.all": "全部",
23
+ "memory.tab.preference": "偏好",
24
+ "memory.tab.project": "项目",
25
+ "memory.tab.decision": "决策",
26
+ "memory.tab.history": "历史"
27
+ },
28
+ en: {
29
+ "memory.panel.title": "Memory",
30
+ "memory.panel.search": "Search memories…",
31
+ "memory.panel.empty": "No memories yet",
32
+ "memory.panel.open": "Memory",
33
+ "memory.tab.all": "All",
34
+ "memory.tab.preference": "Preferences",
35
+ "memory.tab.project": "Projects",
36
+ "memory.tab.decision": "Decisions",
37
+ "memory.tab.history": "History"
38
+ }
39
+ };
40
+
41
+ function typeLabel(t, type) {
42
+ const key = `memory.tab.${type}`;
43
+ const label = t(key);
44
+ return label && label !== key ? label : String(type);
45
+ }
46
+
47
+ function formatDate(value) {
48
+ if (!value) return "—";
49
+ const date = new Date(value);
50
+ return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
51
+ }
52
+
53
+ function MemoryPanel({ t, onClose }) {
54
+ const [tab, setTab] = useState("all");
55
+ const [query, setQuery] = useState("");
56
+ const [items, setItems] = useState([]);
57
+ const [loading, setLoading] = useState(false);
58
+ const abortRef = useRef(null);
59
+
60
+ const load = useCallback(async () => {
61
+ abortRef.current?.abort();
62
+ const controller = new AbortController();
63
+ abortRef.current = controller;
64
+ setLoading(true);
65
+ try {
66
+ const params = new URLSearchParams();
67
+ if (tab !== "all") params.set("type", tab);
68
+ const url = query.trim()
69
+ ? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}`
70
+ : `/api/dsh-mneme/list?${params.toString()}`;
71
+ const res = await fetch(url, { signal: controller.signal });
72
+ const data = await res.json();
73
+ setItems(data.items || []);
74
+ } catch (error) {
75
+ if (error.name === "AbortError") return;
76
+ setItems([]);
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ }, [tab, query]);
81
+
82
+ useEffect(() => {
83
+ load();
84
+ return () => abortRef.current?.abort();
85
+ }, [load]);
86
+
87
+ const tabs = ["all", "preference", "project", "decision", "history"];
88
+
89
+ return createPortal(
90
+ react.createElement("div", { style: styles.overlay },
91
+ react.createElement("div", { style: styles.panel },
92
+ react.createElement("div", { style: styles.header },
93
+ react.createElement("span", { style: styles.title }, t("memory.panel.title")),
94
+ react.createElement("button", { style: styles.close, onClick: onClose }, "×")
95
+ ),
96
+ react.createElement("input", {
97
+ style: styles.search,
98
+ placeholder: t("memory.panel.search"),
99
+ value: query,
100
+ onChange: (e) => setQuery(e.target.value)
101
+ }),
102
+ react.createElement("div", { style: styles.tabs },
103
+ tabs.map((key) =>
104
+ react.createElement("button", {
105
+ key,
106
+ style: { ...styles.tab, ...(tab === key ? styles.tabActive : {}) },
107
+ onClick: () => setTab(key)
108
+ }, t(`memory.tab.${key}`))
109
+ )
110
+ ),
111
+ react.createElement("div", { style: styles.list },
112
+ loading
113
+ ? react.createElement("div", { style: styles.hint }, "…")
114
+ : items.length === 0
115
+ ? react.createElement("div", { style: styles.hint }, t("memory.panel.empty"))
116
+ : items.map((item) =>
117
+ react.createElement("div", { key: item.id, style: styles.card },
118
+ react.createElement("div", { style: styles.cardTitle },
119
+ react.createElement("span", null, item.title),
120
+ react.createElement("span", { style: styles.badge },
121
+ `${typeLabel(t, item.type)} · ★${item.importance}`
122
+ )
123
+ ),
124
+ react.createElement("div", { style: styles.cardContent }, item.content),
125
+ react.createElement("div", { style: styles.cardMeta },
126
+ formatDate(item.updated_at)
127
+ )
128
+ )
129
+ )
130
+ )
131
+ )
132
+ ),
133
+ document.body
134
+ );
135
+ }
136
+
137
+ const styles = {
138
+ overlay: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" },
139
+ panel: { background: "var(--dsw-alias-bg-base, #fff)", borderRadius: 12, width: 640, maxWidth: "90vw", maxHeight: "80vh", display: "flex", flexDirection: "column", padding: 16, boxShadow: "0 8px 40px rgba(0,0,0,0.2)" },
140
+ header: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 },
141
+ title: { fontSize: 16, fontWeight: 600 },
142
+ close: { border: "none", background: "none", fontSize: 20, cursor: "pointer", color: "var(--dsw-alias-label-secondary, #666)" },
143
+ search: { padding: "8px 12px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2, #ddd)", marginBottom: 12, fontSize: 14 },
144
+ tabs: { display: "flex", gap: 6, marginBottom: 12, flexWrap: "wrap" },
145
+ tab: { padding: "4px 10px", borderRadius: 999, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "none", cursor: "pointer", fontSize: 12 },
146
+ tabActive: { background: "var(--dsw-alias-interactive-bg-active, #eee)" },
147
+ list: { overflowY: "auto", display: "flex", flexDirection: "column", gap: 8 },
148
+ hint: { color: "var(--dsw-alias-label-tertiary, #999)", padding: "24px 0", textAlign: "center" },
149
+ card: { border: "1px solid var(--dsw-alias-border-l1, #eee)", borderRadius: 8, padding: "10px 12px" },
150
+ cardTitle: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4, fontSize: 14, fontWeight: 600 },
151
+ badge: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" },
152
+ cardContent: { fontSize: 13, color: "var(--dsw-alias-label-secondary, #555)", marginBottom: 4, whiteSpace: "pre-wrap", wordBreak: "break-word" },
153
+ cardMeta: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" },
154
+ footerButton: { padding: "4px 10px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "none", cursor: "pointer", fontSize: 12, margin: "2px 8px" },
155
+ footerButtonActive: { background: "var(--dsw-alias-interactive-bg-active, #eee)" }
156
+ };
157
+
158
+ function apply(ctx) {
159
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
160
+
161
+ ctx.effect(() => {
162
+ const t = ctx.locale.bind(NS);
163
+ return ctx.slots.inject("sidebar.footer.action", () =>
164
+ ctx.slots.register({
165
+ name: "sidebar.footer.action",
166
+ id: "memory",
167
+ locale: NS,
168
+ inject: () => ({})
169
+ }, () => {
170
+ const [open, setOpen] = react.useState(false);
171
+ return react.createElement(react.Fragment, null,
172
+ react.createElement("button", {
173
+ onClick: () => setOpen(true),
174
+ style: { ...styles.footerButton, ...(open ? styles.footerButtonActive : {}) }
175
+ }, t("memory.panel.open")),
176
+ open && react.createElement(MemoryPanel, { t, onClose: () => setOpen(false) })
177
+ );
178
+ })
179
+ );
180
+ }, "dsh-mneme: sidebar action");
181
+ }
182
+
183
+ exports.apply = apply;
184
+ exports.inject = inject;
185
+ return module.exports;
186
+ }
187
+ });
package/lib/config.js ADDED
@@ -0,0 +1,15 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+
3
+ export const Config = z.object({
4
+ memoryDir: z.string().default("~/.dsh/memory"),
5
+ autoInject: z.boolean().default(true),
6
+ autoSummarize: z.boolean().default(true),
7
+ maxInjectedItems: z.natural().min(1).max(20).default(5),
8
+ importanceThreshold: z.natural().min(1).max(5).default(3),
9
+ autoDream: z.boolean().default(true),
10
+ dreamThresholdCount: z.natural().min(1).max(1000).default(10),
11
+ dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
12
+ dreamDelayMs: z.natural().min(0).max(60000).default(2000),
13
+ dreamProvider: z.string(),
14
+ dreamModel: z.string()
15
+ });
@@ -0,0 +1,121 @@
1
+ const ACTIONS = new Set(["keep", "merge", "archive", "conflict"]);
2
+
3
+ /**
4
+ * Validate a dream decision list against a snapshot of eligible memories.
5
+ * @param decisions - LLM-produced decision list.
6
+ * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
7
+ * @returns {{ok: boolean, errors: string[]}}
8
+ */
9
+ export function validateDecisions(decisions, snapshot) {
10
+ const errors = [];
11
+ if (!Array.isArray(decisions) || decisions.length === 0) {
12
+ return { ok: false, errors: ["decision list must be a non-empty array"] };
13
+ }
14
+ const claimed = new Set();
15
+ for (const [index, d] of decisions.entries()) {
16
+ const at = `decision[${index}]`;
17
+ if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
18
+ errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
19
+ continue;
20
+ }
21
+ const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
22
+ if (d.action === "conflict") {
23
+ if (!d.winner || !d.loser || d.winner === d.loser) {
24
+ errors.push(`${at}: conflict needs distinct winner and loser`);
25
+ continue;
26
+ }
27
+ } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
28
+ errors.push(`${at}: ${d.action} needs non-empty ids`);
29
+ continue;
30
+ }
31
+ for (const id of ids) {
32
+ const mem = snapshot.get(id);
33
+ if (!mem) {
34
+ errors.push(`${at}: unknown id ${JSON.stringify(id)}`);
35
+ } else if (mem.archived || mem.type === "summary") {
36
+ errors.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
37
+ }
38
+ if (claimed.has(id)) {
39
+ errors.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
40
+ }
41
+ claimed.add(id);
42
+ }
43
+ if (d.action === "merge") {
44
+ if (!d.keepSource || !d.ids.includes(d.keepSource)) {
45
+ errors.push(`${at}: merge keepSource must be one of ids`);
46
+ }
47
+ if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
48
+ errors.push(`${at}: merge needs non-empty title and content`);
49
+ }
50
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
51
+ errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
52
+ }
53
+ // Merging across types would blur preference/project/decision boundaries
54
+ // in the injected context; the snapshot carries each entry's type.
55
+ const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
56
+ if (mergeTypes.size > 1) {
57
+ errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
58
+ }
59
+ }
60
+ }
61
+ // Every snapshot id must appear in at least one decision
62
+ for (const id of snapshot.keys()) {
63
+ if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
64
+ }
65
+ return { ok: errors.length === 0, errors };
66
+ }
67
+
68
+ /**
69
+ * Apply a validated decision list to the service. Caller must validate first.
70
+ *
71
+ * Note: merge is intentionally non-atomic — the keeper is updated before the
72
+ * other sources are archived, so a failure between the two never loses content.
73
+ *
74
+ * @param decisions - validated decision list.
75
+ * @param service - memory service (saveWithDedupe/getById/update/setArchived).
76
+ * @param logger - optional logger ({ warn }); per-decision failures are logged.
77
+ * @returns number of applied decisions (archive counts each archived memory as one).
78
+ */
79
+ export function applyDecisions(decisions, service, logger = null) {
80
+ let applied = 0;
81
+ for (const [i, d] of decisions.entries()) {
82
+ try {
83
+ if (d.action === "keep") continue;
84
+ if (d.action === "archive") {
85
+ for (const id of d.ids) {
86
+ const mem = service.getById(id);
87
+ if (mem && !mem.archived) { service.setArchived(id, true); applied++; }
88
+ }
89
+ } else if (d.action === "merge") {
90
+ const keeper = service.getById(d.keepSource);
91
+ if (!keeper || keeper.archived) continue;
92
+ service.update(d.keepSource, {
93
+ title: d.title,
94
+ content: d.content,
95
+ importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
96
+ });
97
+ for (const id of d.ids) {
98
+ if (id !== d.keepSource) {
99
+ const mem = service.getById(id);
100
+ if (mem && !mem.archived) { service.setArchived(id, true); }
101
+ }
102
+ }
103
+ applied++;
104
+ } else if (d.action === "conflict") {
105
+ const winner = service.getById(d.winner);
106
+ const loser = service.getById(d.loser);
107
+ if (!winner || !loser) continue;
108
+ service.update(d.winner, {
109
+ content: `${winner.content}\n\n(已否决旧信息:${[...loser.content].slice(0, 100).join("")})`
110
+ });
111
+ service.setArchived(d.loser, true);
112
+ applied++;
113
+ }
114
+ } catch (error) {
115
+ // Skip individual bad decision; never corrupt the store. The optional
116
+ // logger makes the failure visible instead of failing silently.
117
+ logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
118
+ }
119
+ }
120
+ return applied;
121
+ }