dsh-mem0-memory 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.
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-mem0-import — DSH 会话记忆导入命令(dsh-mem0-memory 三件套之一)
4
+ *
5
+ * 链条:G1 解析(per-session 增量窗口)→ 收集批次 → POST server /import
6
+ * → 全部成功才落 checkpoint(失败不更新,下次重跑,mem0 hash 去重兜底)。
7
+ *
8
+ * 用法:
9
+ * node dsh-mem0-import.mjs # --since-last(默认):读 checkpoint 增量导入
10
+ * node dsh-mem0-import.mjs --full # 全量首导(忽略 checkpoint,导入后建立)
11
+ * node dsh-mem0-import.mjs --session <f> # 只导指定 session 文件
12
+ * node dsh-mem0-import.mjs --dry-run # 只解析统计,不 POST 不落 checkpoint
13
+ *
14
+ * 并发安全:运行前抢 .import.lock(checkpoint 同级)。锁文件只写不删——
15
+ * 下次运行按 PID 存活检测:持有者还活着 = 真锁,本次跳过;已死 = 残留锁,覆盖接管。
16
+ * 失败语义:任一批次 POST 失败 → 退出码 1,checkpoint 不动。
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { spawnSync } from "node:child_process";
24
+
25
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
26
+ const DEFAULT_G1 = path.join(SCRIPT_DIR, "..", "scripts", "g1-session-parser.mjs");
27
+ const DEFAULT_SERVER = "http://127.0.0.1:18200";
28
+ const DEFAULT_DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
29
+ const BATCH_TIMEOUT_MS = 120000;
30
+
31
+ function parseArgs(argv) {
32
+ const get = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
33
+ return {
34
+ help: argv.includes("--help") || argv.includes("-h"),
35
+ full: argv.includes("--full"),
36
+ dryRun: argv.includes("--dry-run"),
37
+ force: argv.includes("--force"),
38
+ session: get("--session"),
39
+ server: get("--server") ?? DEFAULT_SERVER,
40
+ dshHome: get("--dsh-home") ?? DEFAULT_DSH_HOME,
41
+ out: get("--out"),
42
+ logDir: get("--log-dir"),
43
+ script: get("--script") ?? DEFAULT_G1,
44
+ timeoutMs: Number(get("--timeout-ms") ?? BATCH_TIMEOUT_MS),
45
+ };
46
+ }
47
+
48
+ const USAGE = `dsh-mem0-import — DSH 会话记忆导入命令(G1 → mem0-server /import → checkpoint)
49
+
50
+ 用法:
51
+ node dsh-mem0-import.mjs [选项]
52
+
53
+ 选项:
54
+ --since-last(默认) 读 checkpoint 增量导入(无 checkpoint = 全量首导)
55
+ --full 全量导入(忽略 checkpoint,导入后建立)
56
+ --session <file> 只导入指定 session 文件
57
+ --server <url> mem0-server 地址(默认 http://127.0.0.1:18200)
58
+ --dsh-home <dir> DSH 数据根(默认 $DSH_HOME 或 ~/.dsh)
59
+ --out <dir> G1 批次输出目录(默认 $DSH_HOME/tmp/dsh-memory-g1)
60
+ --log-dir <dir> G1 日志目录(默认 $DSH_HOME/logs)
61
+ --script <path> g1-session-parser.mjs 路径(默认同包 scripts/ 下)
62
+ --timeout-ms <N> 单批次 POST 超时(默认 120000)
63
+ --force 接管残留 .import.lock(PID 已死的锁)
64
+ --dry-run 只解析统计,不 POST 不落 checkpoint
65
+ -h, --help 显示本帮助
66
+
67
+ checkpoint: <dsh-home>/mem0/import-state.json({sessionId: lastSeq})
68
+ `;
69
+
70
+ const log = (level, phase, msg) => console.log(`[${new Date().toISOString()}] [${level}] [${phase}] ${msg}`);
71
+
72
+ async function main() {
73
+ const args = parseArgs(process.argv.slice(2));
74
+ if (args.help) {
75
+ console.log(USAGE);
76
+ return 0;
77
+ }
78
+ // 统一绝对路径(G1 由子进程继承 cwd,但 checkpoint/锁必须在稳定位置)
79
+ args.dshHome = path.resolve(args.dshHome);
80
+ if (args.out) args.out = path.resolve(args.out);
81
+ if (args.logDir) args.logDir = path.resolve(args.logDir);
82
+ if (args.session) args.session = path.resolve(args.session);
83
+ if (args.script) args.script = path.resolve(args.script);
84
+
85
+ const checkpointPath = path.join(args.dshHome, "mem0", "import-state.json");
86
+ const lockPath = `${checkpointPath}.lock`;
87
+ const pendingPath = `${checkpointPath}.pending.json`;
88
+
89
+ // ── 锁(只写不删;PID 存活检测区分真锁/残留锁)──
90
+ if (fs.existsSync(lockPath)) {
91
+ let holderPid = null;
92
+ try { holderPid = JSON.parse(fs.readFileSync(lockPath, "utf8")).pid ?? null; } catch {}
93
+ const alive = holderPid != null && (() => { try { process.kill(holderPid, 0); return true; } catch { return false; } })();
94
+ if (alive && !args.force) {
95
+ log("WARN", "lock", `另一导入进程在跑(pid=${holderPid}),本次跳过;确认无进程后可 --force 接管`);
96
+ return 0;
97
+ }
98
+ log("WARN", "lock", alive ? "--force 接管运行中的导入" : `残留锁(pid=${holderPid} 已死),接管`);
99
+ }
100
+ fs.mkdirSync(path.dirname(checkpointPath), { recursive: true });
101
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
102
+
103
+ try {
104
+ // ── 构造 G1 调用 ──
105
+ const g1Args = ["--result-file", pendingPath];
106
+ if (args.session) g1Args.push("--session", args.session);
107
+ if (!args.full && fs.existsSync(checkpointPath)) g1Args.push("--checkpoint", checkpointPath);
108
+ if (args.out) g1Args.push("--out", args.out);
109
+ if (args.logDir) g1Args.push("--log-dir", args.logDir);
110
+ if (args.dryRun) g1Args.push("--dry-run");
111
+
112
+ log("INFO", "import", `G1: node ${args.script} ${g1Args.join(" ")}`);
113
+ // 把 --dsh-home 传给 G1(DSH_HOME env 控制 sessions 扫描根)
114
+ const g1 = spawnSync(process.execPath, [args.script, ...g1Args], {
115
+ encoding: "utf8",
116
+ timeout: 600000,
117
+ env: { ...process.env, DSH_HOME: args.dshHome },
118
+ });
119
+ if (g1.status !== 0) {
120
+ log("ERROR", "import", `G1 失败(exit=${g1.status}): ${(g1.stderr || g1.stdout || "").slice(-1000)}`);
121
+ return 1;
122
+ }
123
+ const g1Tail = (g1.stdout || "").trim().split("\n").slice(-5).join("\n");
124
+ if (g1Tail) log("INFO", "import", `G1 输出尾:\n${g1Tail}`);
125
+ if (args.dryRun) {
126
+ log("INFO", "import", "[dry-run] 不 POST 不落 checkpoint。");
127
+ return 0;
128
+ }
129
+
130
+ // ── 读 G1 result(lastSeq + 本次批次清单)──
131
+ let result;
132
+ try {
133
+ result = JSON.parse(fs.readFileSync(pendingPath, "utf8"));
134
+ } catch (e) {
135
+ log("ERROR", "import", `G1 result 缺失/损坏(${pendingPath}): ${e.message}`);
136
+ return 1;
137
+ }
138
+ const batches = Array.isArray(result.batches) ? result.batches : [];
139
+ const lastSeqMap = (result.lastSeq && typeof result.lastSeq === "object") ? result.lastSeq : {};
140
+ log("INFO", "import", `G1 产出 ${batches.length} 个批次(${Object.keys(lastSeqMap).length} 个 session)`);
141
+
142
+ if (batches.length === 0) {
143
+ // 无新内容:仍把 lastSeq 落为 checkpoint(跟进文件末尾,避免重复解析)
144
+ writeCheckpoint(checkpointPath, lastSeqMap);
145
+ log("INFO", "import", "无新批次,checkpoint 已跟进(无事实变化)");
146
+ return 0;
147
+ }
148
+
149
+ // ── 逐个 POST /import(source=DSH:发布版只有 G1 来源)──
150
+ const outRoot = path.resolve(args.out || path.join(args.dshHome, "tmp", "dsh-memory-g1"));
151
+ let posted = 0, addTotal = 0;
152
+ for (const rel of batches) {
153
+ const batchFile = path.join(outRoot, rel);
154
+ let messages;
155
+ try {
156
+ messages = JSON.parse(fs.readFileSync(batchFile, "utf8"));
157
+ } catch (e) {
158
+ log("ERROR", "import", `批次读取失败 ${rel}: ${e.message}`);
159
+ return 1;
160
+ }
161
+ const body = JSON.stringify({ messages, source: "DSH" });
162
+ log("INFO", "import", `POST /import ${rel}(${messages.length} 条消息)...`);
163
+ const resp = await awaitPost(`${args.server}/import`, body, args.timeoutMs);
164
+ if (!resp.ok) {
165
+ log("ERROR", "import", `POST 失败 ${rel}: ${resp.error}`);
166
+ return 1;
167
+ }
168
+ posted++;
169
+ addTotal += resp.add_count ?? 0;
170
+ log("INFO", "import", ` → add=${resp.add_count ?? "?"} total=${resp.total ?? "?"}`);
171
+ }
172
+
173
+ // ── 全部成功 → 落 checkpoint ──
174
+ writeCheckpoint(checkpointPath, lastSeqMap);
175
+ log("INFO", "import", `完成: ${posted}/${batches.length} 批次导入(add=${addTotal}),checkpoint 已更新`);
176
+ return 0;
177
+ } finally {
178
+ // 锁文件只写不删(AGENTS 红线);下次按 PID 存活检测接管
179
+ log("INFO", "lock", `锁留在 ${lockPath}(pid=${process.pid},下次运行自动判定)`);
180
+ }
181
+ }
182
+
183
+ function writeCheckpoint(checkpointPath, lastSeqMap) {
184
+ const tmp = `${checkpointPath}.tmp`;
185
+ fs.writeFileSync(tmp, JSON.stringify(lastSeqMap, null, 2), "utf8");
186
+ const parsed = JSON.parse(fs.readFileSync(tmp, "utf8")); // 写后校验
187
+ if (!parsed || typeof parsed !== "object") throw new Error("checkpoint 自校验失败");
188
+ fs.renameSync(tmp, checkpointPath);
189
+ log("INFO", "import", `checkpoint 已落盘: ${checkpointPath}(${Object.keys(lastSeqMap).length} session)`);
190
+ }
191
+
192
+ function awaitPost(url, body, timeoutMs) {
193
+ return new Promise((resolve) => {
194
+ const controller = new AbortController();
195
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
196
+ fetch(url, {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body,
200
+ signal: controller.signal,
201
+ })
202
+ .then(async (r) => {
203
+ const data = await r.json().catch(() => ({}));
204
+ resolve({ ok: r.ok && data.ok !== false, add_count: data.add_count, total: data.total, error: data.error || `HTTP ${r.status}` });
205
+ })
206
+ .catch((e) => resolve({ ok: false, error: e.name === "AbortError" ? "timeout" : e.message }))
207
+ .finally(() => clearTimeout(timer));
208
+ });
209
+ }
210
+
211
+ main().then((code) => { process.exitCode = code; }).catch((e) => { console.error(e); process.exitCode = 1; });
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-mem0-search — 主动搜索记忆命令(dsh-mem0-memory 三件套之一)
4
+ *
5
+ * 对 mem0-server /search 的 CLI 封装:像用搜索引擎一样查记忆库。
6
+ * 支持 source 过滤(多源场景)、日期窗口、条数、降级模式标记展示。
7
+ *
8
+ * 用法:
9
+ * dsh-mem0-search "关键词" # 默认搜全部来源,top 5
10
+ * dsh-mem0-search "关键词" --limit 10 # 条数
11
+ * dsh-mem0-search "关键词" --source DSH # 只搜指定来源
12
+ * dsh-mem0-search "关键词" --from 2026-08-01 --to 2026-08-19 # 日期窗口
13
+ * dsh-mem0-search "关键词" --json # 原始 JSON 输出(脚本友好)
14
+ * dsh-mem0-search --server http://127.0.0.1:18200 "关键词" # 自定义服务
15
+ *
16
+ * 输出:每条记忆一行(分数 + 来源 + 内容前 N 字);degraded 时标注
17
+ * [降级=关键词模式],topup 时标注 [含关键词补捞]。
18
+ */
19
+
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const DEFAULT_SERVER = "http://127.0.0.1:18200";
23
+ const DEFAULT_LIMIT = 5;
24
+ // 日期窗口查询走 scroll+重排(慢路径,20-60s),超时要给足——普通查询远快于此
25
+ const TIMEOUT_MS = 60000;
26
+
27
+ function usage() {
28
+ return `dsh-mem0-search — 主动搜索记忆(mem0-server /search 的 CLI 封装)
29
+
30
+ 用法:
31
+ dsh-mem0-search <关键词> [选项]
32
+
33
+ 选项:
34
+ --limit <N> 返回条数(默认 5)
35
+ --source <src> 只搜指定来源(如 DSH;默认全部)
36
+ --from <YYYY-MM-DD> 日期窗口起(含)
37
+ --to <YYYY-MM-DD> 日期窗口止(含)
38
+ --server <url> mem0-server 地址(默认 ${DEFAULT_SERVER})
39
+ --json 输出原始 JSON(脚本友好,含 degraded/topup 标记)
40
+ -h, --help 显示本帮助
41
+
42
+ 示例:
43
+ dsh-mem0-search "上次说的消息格式规范"
44
+ dsh-mem0-search "项目技术栈" --source DSH --limit 10
45
+ dsh-mem0-search "8月的决定" --from 2026-08-01 --to 2026-08-31 --json
46
+ `;
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ const args = { query: "", limit: DEFAULT_LIMIT, source: undefined, from: undefined, to: undefined, server: DEFAULT_SERVER, json: false, help: false };
51
+ const positional = [];
52
+ const valueOpts = new Set(["--limit", "--source", "--from", "--to", "--server"]);
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (valueOpts.has(a)) {
56
+ const v = argv[i + 1];
57
+ if (v !== undefined) {
58
+ if (a === "--limit") args.limit = Number(v);
59
+ else if (a === "--source") args.source = v;
60
+ else if (a === "--from") args.from = v;
61
+ else if (a === "--to") args.to = v;
62
+ else if (a === "--server") args.server = v;
63
+ i++; // 消费选项值,避免被当作位置参数
64
+ }
65
+ } else if (a === "--json") args.json = true;
66
+ else if (a === "--help" || a === "-h") args.help = true;
67
+ else if (!a.startsWith("-")) positional.push(a);
68
+ }
69
+ args.query = positional.join(" ").trim();
70
+ return args;
71
+ }
72
+
73
+ async function main() {
74
+ const args = parseArgs(process.argv.slice(2));
75
+ if (args.help || !args.query) {
76
+ console.log(usage());
77
+ return args.help ? 0 : 1;
78
+ }
79
+
80
+ const body = { query: args.query, limit: args.limit };
81
+ if (args.source) body.source = args.source;
82
+ if (args.from) body.date_from = args.from; // 纯日期 YYYY-MM-DD(server 按 ts_day 前缀比较)
83
+ if (args.to) body.date_to = args.to;
84
+
85
+ const controller = new AbortController();
86
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
87
+ try {
88
+ const resp = await fetch(`${args.server}/search`, {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify(body),
92
+ signal: controller.signal,
93
+ });
94
+ const data = await resp.json().catch(() => ({}));
95
+ if (!resp.ok || data.ok === false) {
96
+ console.error(`搜索失败: ${data.error || `HTTP ${resp.status}`}`);
97
+ return 1;
98
+ }
99
+
100
+ if (args.json) {
101
+ console.log(JSON.stringify(data, null, 2));
102
+ return 0;
103
+ }
104
+
105
+ const results = data.results || [];
106
+ console.log(`「${args.query}」→ ${results.length} 条记忆` +
107
+ (data.mode === "keyword" ? "([降级] 关键词模式)" : "") +
108
+ ((data.topup || 0) > 0 ? `(含关键词补捞 +${data.topup})` : "") +
109
+ `,耗时 ${data.elapsed_ms}ms`);
110
+ for (const r of results) {
111
+ const src = (r.metadata && r.metadata.source) ? `[${r.metadata.source}]` : "";
112
+ const ts = (r.metadata && r.metadata.timestamp) ? r.metadata.timestamp.slice(0, 10) : "";
113
+ console.log(` ${String(r.score).slice(0, 5)} ${src} ${ts} ${String(r.memory || "").slice(0, 80)}`);
114
+ }
115
+ return 0;
116
+ } catch (e) {
117
+ console.error(`请求失败: ${e.name === "AbortError" ? "超时" : e.message}`);
118
+ return 1;
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+
124
+ main().then((code) => { process.exitCode = code; }).catch((e) => { console.error(e); process.exitCode = 1; });
@@ -0,0 +1,4 @@
1
+ # dsh-mem0-memory: recall Mem0 memories via agent/pre-step injection.
2
+ - insert:
3
+ - id: mem0-memory
4
+ name: 'dsh-mem0-memory'
@@ -0,0 +1,4 @@
1
+ {
2
+ "note": "G1 工作区排除名单模板(复制为 excluded-workspaces.json 后生效)。【规则优先级】①前缀约定(写死于 g1-session-parser.mjs):工作区目录名以 0_ 开头 = 不入库(未来新建不入库的工作区按此命名,零维护);②本文件 excluded 数组:仅历史遗留区(名称无 0_ 前缀但必须排除)。匹配:excluded 每条按「子串」匹配「解码后的工作区路径」或「原始 projectKey」,任一命中即整体排除该工作区。配置文件缺失或损坏时 G1 直接报错退出(fail-closed,拒绝无保护运行)。",
3
+ "excluded": []
4
+ }
package/lib/index.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * dsh-mem0-memory — DSH 薄桥插件
3
+ *
4
+ * 监听 `agent/pre-step`,取进入步骤的最后一条真实 user 消息作查询,
5
+ * 调 mem0-server(127.0.0.1:18200 /search)召回相关记忆,
6
+ * 把 <relevant-memories> 块注入到步骤消息批次(在 claimed 消息之后插入),
7
+ * 等效 OpenClaw mem0-memory 插件的 before_prompt_build + appendSystemContext。
8
+ *
9
+ * 后处理移植自 OpenClaw 侧 recall.js(🔵 已读源码):
10
+ * - 词法重叠 boost(≤0.2)
11
+ * - 精确去重 + 中文 bigram Jaccard 语义去重(阈值 0.4)
12
+ * - scoreThreshold 过滤(默认 0.15)+ limit(默认 5)+ tokenBudget(默认 2000)
13
+ *
14
+ * 设计原则(主文档 §3.3):**薄**——单次 HTTP + 纯 JS 后处理,不做多轮 agent 循环。
15
+ * (OpenClaw active-memory 死于过度设计,mem0-memory 活是因为简单直接。)
16
+ *
17
+ * 配置(profile cordis.patch.yml 覆盖):
18
+ * - id: mem0-memory
19
+ * config:
20
+ * serverUrl: http://127.0.0.1:18200
21
+ * enabled: true
22
+ * limit: 5 # 注入条数上限
23
+ * scoreThreshold: 0.15
24
+ * tokenBudget: 2000
25
+ * source: DSH # 可选:仅召回指定 source(G4)
26
+ * probePath: "" # 可选:排障探针日志文件路径(默认空=关闭;设置后才写文件)
27
+ * autoImport: true # 会话结束自动增量导入(默认开;关 = 手动 dsh-mem0-import)
28
+ */
29
+
30
+ import path from "node:path";
31
+ import { spawn } from "node:child_process";
32
+ import { fileURLToPath } from "node:url";
33
+ import { randomUUID } from "node:crypto";
34
+
35
+ const PKG_DIR = path.dirname(fileURLToPath(import.meta.url));
36
+
37
+ const MEM0_SERVER = "http://127.0.0.1:18200";
38
+ // 召回超时对齐 OpenClaw mem0-memory 插件(index.js timeoutMs: 20000)。
39
+ // 实测 mem0 /search 冷/热态 5-10s(embedding 走 SiliconFlow),5s 必超时。
40
+ const MEM0_SEARCH_TIMEOUT_MS = 20000;
41
+ const MAX_BLOCK_CHARS = 8000; // 注入块字符上限(约 tokenBudget*4 的保险)
42
+
43
+ // ─── 后处理(移植 OpenClaw recall.js)────────────────────────────────────
44
+ const RECALL_STOPWORDS = new Set([
45
+ "what", "when", "where", "which", "who", "whom", "whose", "why", "how",
46
+ "did", "does", "is", "are", "was", "were", "the", "and", "for", "with",
47
+ "from", "that", "this", "your", "you",
48
+ ]);
49
+ const RECALL_TOKEN_RE = /[a-z0-9\u4e00-\u9fa5]{2,}/gi;
50
+
51
+ function buildQueryProfile(queryText) {
52
+ const text = String(queryText || "").trim();
53
+ const allTokens = text.toLowerCase().match(RECALL_TOKEN_RE) ?? [];
54
+ return { tokens: allTokens.filter((t) => !RECALL_STOPWORDS.has(t)) };
55
+ }
56
+
57
+ function lexicalOverlapBoost(tokens, text) {
58
+ if (tokens.length === 0 || !text) return 0;
59
+ const haystack = ` ${text.toLowerCase()} `;
60
+ let matched = 0;
61
+ for (const token of tokens.slice(0, 8)) {
62
+ if (haystack.includes(` ${token} `) || haystack.includes(token)) matched += 1;
63
+ }
64
+ return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
65
+ }
66
+
67
+ function extractContentWords(text) {
68
+ const normalized = String(text || "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, " ").trim();
69
+ const words = normalized.split(/\s+/).filter((w) => w.length >= 2);
70
+ const ngrams = new Set();
71
+ for (const word of words) {
72
+ if (/[\p{Script=Han}]/u.test(word)) {
73
+ for (let i = 0; i < word.length - 1; i++) ngrams.add(word.slice(i, i + 2));
74
+ } else {
75
+ ngrams.add(word);
76
+ }
77
+ }
78
+ return ngrams;
79
+ }
80
+
81
+ function jaccard(a, b) {
82
+ if (a.size === 0 || b.size === 0) return 0;
83
+ let inter = 0;
84
+ for (const w of a) if (b.has(w)) inter++;
85
+ const union = a.size + b.size - inter;
86
+ return union === 0 ? 0 : inter / union;
87
+ }
88
+
89
+ function semanticDedup(items, threshold = 0.4) {
90
+ const result = [];
91
+ const sets = items.map((i) => extractContentWords(i.memory ?? ""));
92
+ for (let i = 0; i < items.length; i++) {
93
+ let dup = false;
94
+ for (const kept of result) {
95
+ const idx = items.indexOf(kept);
96
+ if (jaccard(sets[i], sets[idx]) >= threshold) { dup = true; break; }
97
+ }
98
+ if (!dup) result.push(items[i]);
99
+ }
100
+ return result;
101
+ }
102
+
103
+ function pickMemories(items, query, limit, scoreThreshold) {
104
+ const profile = buildQueryProfile(query);
105
+ const sorted = [...items].sort((a, b) => {
106
+ const sa = (a.score ?? 0) + lexicalOverlapBoost(profile.tokens, a.memory ?? "");
107
+ const sb = (b.score ?? 0) + lexicalOverlapBoost(profile.tokens, b.memory ?? "");
108
+ return sb - sa;
109
+ });
110
+ const seen = new Set();
111
+ const deduped = [];
112
+ for (const item of sorted) {
113
+ const key = `memory:${String(item.memory ?? "").toLowerCase().replace(/\s+/g, " ").slice(0, 200)}`;
114
+ if (seen.has(key)) continue;
115
+ seen.add(key);
116
+ deduped.push(item);
117
+ }
118
+ return semanticDedup(deduped)
119
+ .filter((item) => (item.score ?? 0) >= scoreThreshold)
120
+ .slice(0, limit);
121
+ }
122
+
123
+ function formatBlock(items) {
124
+ if (items.length === 0) return "";
125
+ const lines = [
126
+ "[MEM0-RECALL]",
127
+ "<relevant-memories>",
128
+ "以下是从 Mem0 长期记忆中召回的相关记忆,优先参考这些内容回答用户问题。",
129
+ ];
130
+ let used = 0;
131
+ for (const item of items) {
132
+ const content = String(item.memory ?? "").trim();
133
+ if (!content) continue;
134
+ const entry = `<memory id="${item.id}">\n${content}\n</memory>`;
135
+ if (used + entry.length + 1 > MAX_BLOCK_CHARS) break;
136
+ lines.push(entry);
137
+ used += entry.length + 1;
138
+ }
139
+ if (lines.length <= 3) return "";
140
+ lines.push("</relevant-memories>");
141
+ return lines.join("\n");
142
+ }
143
+
144
+ // ─── 召回(单次 HTTP,薄)────────────────────────────────────────────────
145
+ async function recallMemories(config, query) {
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs ?? MEM0_SEARCH_TIMEOUT_MS);
148
+ try {
149
+ const body = { query, limit: 20 };
150
+ if (config.source) body.source = config.source; // G4: 按来源过滤
151
+ const resp = await fetch(`${config.serverUrl ?? MEM0_SERVER}/search`, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify(body),
155
+ signal: controller.signal,
156
+ });
157
+ if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
158
+ const data = await resp.json();
159
+ if (!data.ok) return { ok: false, error: data.error || "search failed" };
160
+ const picked = pickMemories(data.results ?? [], query, config.limit ?? 5, config.scoreThreshold ?? 0.15);
161
+ const block = formatBlock(picked);
162
+ return { ok: true, block, count: picked.length, results: picked };
163
+ } catch (e) {
164
+ return { ok: false, error: e.name === "AbortError" ? "timeout" : e.message };
165
+ } finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+
170
+ // ─── 消息提取(取最后一条真实 user 消息)─────────────────────────────────
171
+ function flattenMessageContent(content) {
172
+ if (typeof content === "string") return content;
173
+ if (Array.isArray(content)) {
174
+ return content.map((b) => {
175
+ if (typeof b === "string") return b;
176
+ if (b && typeof b === "object") {
177
+ if (typeof b.text === "string") return b.text;
178
+ if (typeof b.content === "string") return b.content;
179
+ }
180
+ return "";
181
+ }).join("");
182
+ }
183
+ if (content && typeof content === "object") {
184
+ if (typeof content.text === "string") return content.text;
185
+ }
186
+ return "";
187
+ }
188
+
189
+ function extractLastUserText(messages) {
190
+ if (!Array.isArray(messages)) return null;
191
+ for (let i = messages.length - 1; i >= 0; i--) {
192
+ const msg = messages[i];
193
+ if (!msg || msg.role !== "user") continue;
194
+ const text = flattenMessageContent(msg.content).trim();
195
+ if (text && !text.includes("<relevant-memories>")) return text;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ // ─── 插件入口 ────────────────────────────────────────────────────────────
201
+ export const name = "dsh-mem0-memory";
202
+ export const inject = ["agents"];
203
+
204
+ export async function apply(ctx, config = {}) {
205
+ // 探针(排障用,默认关闭):设置 config.probePath 后,apply 执行即写文件——
206
+ // 用于区分「插件未加载」vs「加载了但 pre-step 未触发」。默认不写任何文件。
207
+ const probePath = config.probePath ? path.resolve(String(config.probePath)) : "";
208
+ const probe = async (msg) => {
209
+ if (!probePath) return;
210
+ try {
211
+ const fs = await import("node:fs");
212
+ fs.appendFileSync(probePath, `[${new Date().toISOString()}] ${msg}\n`, "utf8");
213
+ } catch (e) {}
214
+ };
215
+ await probe(`apply() executed, config=${JSON.stringify(config)}`);
216
+
217
+ const cfg = {
218
+ enabled: config.enabled !== false,
219
+ serverUrl: config.serverUrl ?? MEM0_SERVER,
220
+ limit: config.limit ?? 5,
221
+ scoreThreshold: config.scoreThreshold ?? 0.15,
222
+ tokenBudget: config.tokenBudget ?? 2000,
223
+ timeoutMs: config.timeoutMs ?? MEM0_SEARCH_TIMEOUT_MS,
224
+ source: config.source, // 可选
225
+ autoImport: config.autoImport !== false, // 会话结束自动增量导入(默认开,三档机制①)
226
+ };
227
+ if (!cfg.enabled) {
228
+ ctx.logger?.info("[mem0-memory] disabled");
229
+ return;
230
+ }
231
+
232
+ // ─── 会话结束自动增量导入(P2.4:session/disposed 钩子)───────────────
233
+ // fire-and-forget:不阻塞 dispose;子进程间有 .import.lock 互斥(并发安全)。
234
+ // import 脚本定位:插件包内 bin/dsh-mem0-import.mjs(无需用户配置路径)。
235
+ if (cfg.autoImport) {
236
+ const importCmd = path.join(PKG_DIR, "bin", "dsh-mem0-import.mjs");
237
+ ctx.on("session/disposed", (session) => {
238
+ const sessionId = session?.id ?? session?.sessionId ?? "";
239
+ probe(`session/disposed fired: ${sessionId}`);
240
+ spawn(process.execPath, [importCmd, "--since-last"], {
241
+ stdio: "ignore",
242
+ detached: false,
243
+ }).on("error", (e) => {
244
+ ctx.logger?.warn(`[mem0-memory] auto-import spawn failed: ${e.message}`);
245
+ });
246
+ ctx.logger?.info(`[mem0-memory] auto-import triggered (session ${sessionId || "?"})`);
247
+ });
248
+ ctx.logger?.info("[mem0-memory] auto-import armed (session/disposed)");
249
+ }
250
+
251
+ ctx.on("agent/pre-step", async ({ messages, signal }, next) => {
252
+ // 探针(排障用):pre-step 触发即写文件
253
+ await probe(`pre-step fired, messages=${Array.isArray(messages) ? messages.length : "?"}, roles=${Array.isArray(messages) ? messages.map(m => m.role).join(",") : "?"}`);
254
+
255
+ // 先让下游决定本步骤的消息批次
256
+ const decision = await next();
257
+ await probe(`decision kind=${decision.kind}, decision.messages=${Array.isArray(decision.messages) ? decision.messages.length : "?"}`);
258
+
259
+ if (decision.kind !== "enter" || !Array.isArray(decision.messages)) return decision;
260
+
261
+ try {
262
+ const query = extractLastUserText(decision.messages);
263
+ await probe(`query extracted: ${query ? query.slice(0, 80) : "NULL"}`);
264
+ if (!query) return decision;
265
+
266
+ const result = await recallMemories(cfg, query);
267
+ await probe(`recall result: ok=${result.ok}, count=${result.count ?? "?"}, block=${(result.block || "").length}chars, error=${result.error || "none"}`);
268
+ if (!result.ok || !result.block) {
269
+ ctx.logger?.info(`[mem0-memory] recall skip: ${result.error || "no block"} (query="${query.slice(0, 60)}")`);
270
+ return decision;
271
+ }
272
+
273
+ ctx.logger?.info(`[mem0-memory] recall ${result.count} memories (${result.block.length} chars)`);
274
+
275
+ // 注入:在最后一条 claimed 消息之后插入记忆块。
276
+ // 必须带消息 id 且 content 用文本块数组 —— DSH 的 user/message 事件校验要求
277
+ // message.id 为非空字符串、content 为块数组,缺失会把整个 session 持久化日志判为损坏。
278
+ const lastClaimedIndex = decision.messages.findLastIndex((message) => messages.includes(message));
279
+ await probe(`lastClaimedIndex=${lastClaimedIndex}`);
280
+ const injected = {
281
+ role: "user",
282
+ id: randomUUID(),
283
+ content: [{ type: "text", text: result.block }],
284
+ source: { kind: "plugin", plugin: "dsh-mem0-memory", form: "recall" },
285
+ };
286
+ const finalMessages = decision.messages.toSpliced(lastClaimedIndex + 1, 0, injected);
287
+ await probe(`injected! finalMessages=${finalMessages.length}`);
288
+ return {
289
+ kind: "enter",
290
+ messages: finalMessages,
291
+ };
292
+ } catch (e) {
293
+ await probe(`EXCEPTION: ${e.message}`);
294
+ // 绝不让记忆召回破坏步骤(退化:不注入)
295
+ ctx.logger?.warn(`[mem0-memory] recall failed: ${e.message}`);
296
+ return decision;
297
+ }
298
+ });
299
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "dsh-mem0-memory",
3
+ "version": "0.1.0",
4
+ "description": "Thin bridge: recall Mem0 memories into DSH agent context via agent/pre-step injection.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-mem0-import": "bin/dsh-mem0-import.mjs",
9
+ "dsh-mem0-search": "bin/dsh-mem0-search.mjs"
10
+ },
11
+ "files": [
12
+ "lib/index.js",
13
+ "bin/dsh-mem0-import.mjs",
14
+ "bin/dsh-mem0-search.mjs",
15
+ "scripts/g1-session-parser.mjs",
16
+ "scripts/mem0-server.py",
17
+ "scripts/setup.py",
18
+ "scripts/start-server.bat",
19
+ "scripts/start-server.sh",
20
+ "scripts/.env.example",
21
+ "scripts/excluded-workspaces.json",
22
+ "cordis.patch.yml",
23
+ "excluded-workspaces.example.json",
24
+ "README.md",
25
+ "LICENSE",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ }
32
+ },
33
+ "peerDependencies": {
34
+ "@deepseek-ai/cordis": "^4.0.1",
35
+ "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6"
36
+ },
37
+ "license": "MIT"
38
+ }