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,816 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * g1-session-parser.mjs — DSH 会话日志 → mem0 记忆批次解析器
4
+ *
5
+ * 【定位】
6
+ * DSH session(原始日志)→ G1 解析器 → 增量窗口 → mem0(唯一事实源)。
7
+ * 本脚本是 G1 解析器主实现,负责把 DSH 的对话日志翻译成记忆批次。
8
+ *
9
+ * 【输入】 ~/.dsh/sessions/<projectKey>/<sessionId>/session.jsonl.zstd
10
+ * (zstd 帧容器:header 帧 + append 帧;事件 JSONL)
11
+ * 【输出】 与 mem0 对话批次同构的数组 batch:
12
+ * <out>/<YYYY-MM-DD>/P0_NNN.json
13
+ * ([{role:"user"|"assistant", content:"[时间 渠道] 内容"}, ...])
14
+ *
15
+ * 【session 选择规则】Level 0 硬过滤:
16
+ * - 文件 size === 0 → 跳过(空壳 session,无帧可解)
17
+ * - delegationDepth >= 1 → 跳过(子代理 session;报告已 relay 进主 session,
18
+ * 无信息增量。--include-subagents 可放宽)
19
+ * - 无真实 user/message 的 session → 跳过(纯机械/系统 session 无记忆价值,
20
+ * 对齐 isSyntheticSession 哲学。--include-empty 可放宽)
21
+ * - 排除工作区:独立工作区(如金融/个人专区)不得进入记忆库,防止污染
22
+ * 跨会话记忆。规则:①**前缀约定(写死)**——工作区目录名以 `0_` 开头 =
23
+ * 不入库(未来新建工作区按此命名,零维护);②excluded-workspaces.json
24
+ * 名单(仅历史遗留区);配置缺失/损坏 → fail-closed 退出(拒绝无保护运行)
25
+ *
26
+ * 【消息提取规则】(核心:对齐回合级语义)
27
+ * - 按「真实 user/message」锚点分组(一个 user 及其后的所有 assistant 为一组)
28
+ * - 每组只保留最后一条 assistant/message(= 最终交付回复,含整个 turn 的
29
+ * 完整总结;中间消息是思考/工作过程,易产冗余 fact——实测 89% 为中间噪音)
30
+ * - 无 user 配对的 assistant 消息丢弃
31
+ * - source.kind 注入过滤(非真实用户输入):agent-instructions / skill-catalog /
32
+ * plugin / subagent-settled / subagent-report / goal / system / hook / compaction
33
+ * - 内容噪音过滤 + 凭据模式过滤
34
+ *
35
+ * 【增量窗口】
36
+ * --since-seq N:只处理 seq > N 的事件(真增量 checkpoint)
37
+ */
38
+
39
+ import fs from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import { zstdDecompressSync } from "node:zlib";
44
+
45
+ // ─── 配置 ────────────────────────────────────────────────────────────────
46
+ // 路径全部可经环境变量/CLI 覆盖,默认落在 $DSH_HOME 下:
47
+ // DSH_HOME DSH 数据根目录(默认 ~/.dsh)
48
+ // --out <dir> 批次输出目录(默认 $DSH_HOME/tmp/dsh-memory-g1)
49
+ // --log-dir <dir> 日志目录(默认 $DSH_HOME/logs)
50
+ const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
51
+ const SESSIONS_ROOT = path.join(DSH_HOME, "sessions");
52
+ const OUT_ROOT = path.join(DSH_HOME, "tmp", "dsh-memory-g1");
53
+ const MAX_CONTENT_CHARS = 10000; // 与 mem0 MAX_CHARS 对齐
54
+ const MAX_MSGS_PER_BATCH = 60; // 与 mem0 MAX_MSGS 对齐
55
+ const DEFAULT_LOG_DIR = path.join(DSH_HOME, "logs");
56
+
57
+ // ─── 工作区排除(无关工作区不得污染记忆库)──────────────────────────────
58
+ // 【背景】G1 遍历 $DSH_HOME/sessions/ 下所有 projectKey(= 一个工作区)。
59
+ // 独立 KM 工作区(金融/个人专区等)不得进入记忆库。
60
+ // 【规则】未来新建工作区:不入库的 → 目录名加前缀 `0_`(工作区名称 = 目录名
61
+ // basename,源码实证 dsh-workspace)。前缀命中 = 写死约定,无需任何配置;
62
+ // 非 `0_` 前缀的工作区一律入库。
63
+ // 【配置】excluded-workspaces.json 的 excluded 数组(仅历史遗留区——名称无
64
+ // 前缀但必须排除);每条按子串匹配「解码后的工作区路径」或「原始
65
+ // projectKey」,任一命中即整体排除该工作区。
66
+ // 【安全】配置文件缺失/损坏 → 抛错退出(fail-closed:宁可今天不跑,
67
+ // 也不可无保护地把无关工作区灌进记忆库)。
68
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
69
+ const EXCLUDED_WORKSPACES_FILE = path.join(SCRIPT_DIR, "excluded-workspaces.json");
70
+ /** 不入库的工作区命名前缀(写死约定:未来新建不入库的工作区以此命名) */
71
+ const EXCLUDE_PREFIX = "0_";
72
+
73
+ function loadExcludedWorkspaces() {
74
+ if (!fs.existsSync(EXCLUDED_WORKSPACES_FILE)) {
75
+ throw new Error(`工作区排除配置文件缺失: ${EXCLUDED_WORKSPACES_FILE}(fail-closed,拒绝无保护运行)`);
76
+ }
77
+ let cfg;
78
+ try {
79
+ cfg = JSON.parse(fs.readFileSync(EXCLUDED_WORKSPACES_FILE, "utf8"));
80
+ } catch (e) {
81
+ throw new Error(`工作区排除配置文件解析失败: ${EXCLUDED_WORKSPACES_FILE} — ${e.message}`);
82
+ }
83
+ const list = cfg && Array.isArray(cfg.excluded) ? cfg.excluded : null;
84
+ if (!list) throw new Error(`工作区排除配置缺少 excluded 数组: ${EXCLUDED_WORKSPACES_FILE}`);
85
+ return list.map((s) => String(s).trim()).filter(Boolean);
86
+ }
87
+
88
+ /** projectKey → 可读路径(实测编码 2026-08-19:路径段以 '-' 连接;段内非 ASCII
89
+ * 字符转义为连续 ~XXXX,XXXX = UTF-16BE hex,如 中=~4E2D 文=~6587 对=~5BF9
90
+ * 话=~8BDD,一个目录名「中文对话」= 一整段 ~4E2D~6587~5BF9~8BDD,
91
+ * 此例亦说明中文工作区名的编码形态)。
92
+ * 注意:目录名本身含 '-' 时解码有歧义(如 DSH-ws1 → DSH\ws1),仅作可读
93
+ * 参考与子串匹配用,不作为唯一依据(原始 projectKey 同时参与匹配)。 */
94
+ function decodeProjectKey(key) {
95
+ const decoded = String(key).replace(/~([0-9A-Fa-f]{4})/g, (m, hex) => {
96
+ try { return String.fromCharCode(parseInt(hex, 16)); } catch { return m; }
97
+ });
98
+ return decoded.split("-").filter(Boolean).join("\\");
99
+ }
100
+
101
+ function isWorkspaceExcluded(projectKey, decoded, excluded) {
102
+ return excluded.some((e) => projectKey.includes(e) || decoded.includes(e));
103
+ }
104
+
105
+ /** 前缀规则(写死约定):不入库的工作区 = 名称以 `0_` 开头。名称有两处
106
+ * 载体:①目录名(= 创建时的默认 title,路径派生 projectKey);②台账标题
107
+ * (~/.dsh/storages/workspace.json 的 title,UI 可改名,不影响磁盘目录)。
108
+ * 三个命中渠道:解码路径任一段 / 原始 projectKey 任一段 / 台账标题,
109
+ * 任一命中即排除。
110
+ * @returns 命中渠道数组(空数组 = 未命中) */
111
+ function prefixedChannels(projectKey, decoded, record) {
112
+ const ch = [];
113
+ if (decoded.split("\\").some((s) => s.startsWith(EXCLUDE_PREFIX))) ch.push("前缀0_(路径)");
114
+ if (projectKey.split("-").some((s) => s.startsWith(EXCLUDE_PREFIX))) ch.push("前缀0_(projectKey)");
115
+ if (record && String(record.title).startsWith(EXCLUDE_PREFIX)) ch.push("前缀0_(标题)");
116
+ return ch;
117
+ }
118
+
119
+ function isPrefixedWorkspace(projectKey, decoded, record) {
120
+ return prefixedChannels(projectKey, decoded, record).length > 0;
121
+ }
122
+
123
+ // ─── DSH 工作区台账(名称的权威载体)─────────────────────────────────────
124
+ // workspace.json 是 DSH 全局台账:path + title + sessionIds。projectKey 由
125
+ // 路径派生,但 UI 可改 title(不动目录)——前缀规则必须同时看 title。
126
+ // 台账读取失败仅 WARN 降级(标题通道是补充;主闸门 excluded-workspaces.json
127
+ // 仍是 fail-closed 硬闸门)。
128
+ let _workspaceRegistry = null;
129
+
130
+ function normalizeWsPath(p) {
131
+ return String(p).replace(/\\+$/, "").toLowerCase();
132
+ }
133
+
134
+ function loadWorkspaceRegistry() {
135
+ if (_workspaceRegistry) return _workspaceRegistry;
136
+ _workspaceRegistry = new Map(); // 规范化路径(小写) -> { path, title }
137
+ try {
138
+ const file = path.join(DSH_HOME, "storages", "workspace.json");
139
+ if (!fs.existsSync(file)) return _workspaceRegistry;
140
+ const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
141
+ const tables = cfg && cfg.tables && cfg.tables.workspaces ? cfg.tables.workspaces : {};
142
+ for (const rec of Object.values(tables)) {
143
+ if (rec && typeof rec.path === "string" && typeof rec.title === "string") {
144
+ _workspaceRegistry.set(normalizeWsPath(rec.path), rec);
145
+ }
146
+ }
147
+ } catch (e) {
148
+ log("WARN", "registry", `工作区台账读取失败(前缀0_标题通道降级): ${e.message}`);
149
+ }
150
+ return _workspaceRegistry;
151
+ }
152
+
153
+ /** 由解码后的工作区路径查台账记录;basename 兜底(路径大小写/尾斜杠差异) */
154
+ function registryRecordFor(decoded) {
155
+ const rec = loadWorkspaceRegistry().get(normalizeWsPath(decoded));
156
+ if (rec) return rec;
157
+ const last = decoded.split("\\").filter(Boolean).pop();
158
+ if (!last) return null;
159
+ const lc = last.toLowerCase();
160
+ for (const r of loadWorkspaceRegistry().values()) {
161
+ const rLast = r.path.split("\\").filter(Boolean).pop();
162
+ if (rLast && rLast.toLowerCase() === lc) return r;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // ─── Logger(复杂管线工程守则:必须有足够追查问题根因的 log)─────────────
168
+ // 统一日志:时间戳 + 级别 + 环节 + 明细;stdout 实时 + --log-file 落盘(默认 mem0-daily-logs/g1-<date>.log)
169
+ // 级别:INFO(正常流程)/ WARN(可恢复异常)/ ERROR(失败,含堆栈)
170
+ let _logFile = null;
171
+ let _logFd = null;
172
+
173
+ function initLogger(dateStr, logDir) {
174
+ try {
175
+ const dir = logDir || DEFAULT_LOG_DIR;
176
+ fs.mkdirSync(dir, { recursive: true });
177
+ _logFile = path.join(dir, `g1-${dateStr || "unknown"}.log`);
178
+ _logFd = fs.openSync(_logFile, "a");
179
+ log("INFO", "init", `日志文件: ${_logFile}`);
180
+ } catch (e) {
181
+ console.error(`[g1] 无法初始化日志文件: ${e.message}`);
182
+ }
183
+ }
184
+
185
+ function closeLogger() {
186
+ try { if (_logFd !== undefined) fs.closeSync(_logFd); } catch {}
187
+ _logFd = undefined;
188
+ }
189
+
190
+ function log(level, phase, msg, extra) {
191
+ const ts = new Date().toISOString();
192
+ const line = `[${ts}] [${level}] [${phase}] ${msg}`;
193
+ console.log(line);
194
+ if (_logFd !== undefined) {
195
+ try { fs.writeSync(_logFd, line + "\n"); } catch {}
196
+ }
197
+ if (extra !== undefined) {
198
+ const extraLine = `[${ts}] [${level}] [${phase}] └─ ${String(extra).slice(0, 2000)}`;
199
+ console.log(extraLine);
200
+ if (_logFd !== undefined) {
201
+ try { fs.writeSync(_logFd, extraLine + "\n"); } catch {}
202
+ }
203
+ }
204
+ }
205
+
206
+ // ─── 续号 + staging(补跑/双源共存支持)───────────────────────────────────
207
+ // 目标目录已有 P0_*.json 时,从最大编号 +1 续写(不覆盖已有 batch)。
208
+ // 输出先写 staging 目录(<out>-staging),自校验通过后才拷贝到正式目录
209
+ // (不污染,失败可重来)。
210
+ function stagingRootFor(out) {
211
+ return `${out}-staging`;
212
+ }
213
+
214
+ /** 扫描目录已有 P0_*.json 的最大编号,返回 N+1(无则 1)。不存在目录返回 1。 */
215
+ function nextP0Number(dateDir) {
216
+ try {
217
+ if (!fs.existsSync(dateDir)) return 1;
218
+ const files = fs.readdirSync(dateDir).filter((f) => /^P0_\d{3}\.json$/.test(f));
219
+ if (files.length === 0) return 1;
220
+ const maxN = Math.max(...files.map((f) => parseInt(f.slice(3, 6), 10)));
221
+ return maxN + 1;
222
+ } catch (e) {
223
+ log("WARN", "nextP0Number", `扫描目录失败,从 1 开始: ${e.message}`);
224
+ return 1;
225
+ }
226
+ }
227
+
228
+ // 渠道映射(按 session 目录名推断渠道名)
229
+ const CHANNEL_MAP = [
230
+ [/lark-link/i, "飞书"],
231
+ [/wechat|wx-/i, "微信"],
232
+ [/webchat|dash|session-/i, "WebChat"],
233
+ ];
234
+
235
+ // ─── zstd 帧扫描(移植自 dsh-session-persistence-jsonl,🔵 已验证)──────
236
+ const ZSTD_MAGIC = 4247762216; // 0xFD2FB528
237
+
238
+ function scanZstdFrames(buffer) {
239
+ const frames = [];
240
+ let offset = 0;
241
+ while (offset < buffer.length) {
242
+ const start = offset;
243
+ if (buffer.length - offset < 4) break;
244
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break;
245
+ offset += 4;
246
+ if (offset === buffer.length) break;
247
+ const descriptor = buffer.readUInt8(offset);
248
+ offset += 1;
249
+ const contentSizeFlag = descriptor >>> 6;
250
+ const singleSegment = (descriptor & 32) !== 0;
251
+ const checksum = (descriptor & 4) !== 0;
252
+ const dictionaryFlag = descriptor & 3;
253
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
254
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
255
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
256
+ if (buffer.length - offset < remainingHeaderBytes) break;
257
+ offset += remainingHeaderBytes;
258
+ for (;;) {
259
+ if (buffer.length - offset < 3) return { frames, ok: false };
260
+ const blockHeader = buffer.readUIntLE(offset, 3);
261
+ offset += 3;
262
+ const lastBlock = (blockHeader & 1) !== 0;
263
+ const blockType = (blockHeader >>> 1) & 3;
264
+ const blockSize = blockHeader >>> 3;
265
+ if (blockType === 3) return { frames, ok: false };
266
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
267
+ if (buffer.length - offset < payloadBytes) return { frames, ok: false };
268
+ offset += payloadBytes;
269
+ if (lastBlock) break;
270
+ }
271
+ if (checksum) {
272
+ if (buffer.length - offset < 4) return { frames, ok: false };
273
+ offset += 4;
274
+ }
275
+ frames.push({ start, end: offset });
276
+ }
277
+ return { frames, ok: true };
278
+ }
279
+
280
+ function decodeSessionLog(file) {
281
+ const buf = fs.readFileSync(file);
282
+ if (buf.length === 0) return { text: "", empty: true };
283
+ const { frames, ok } = scanZstdFrames(buf);
284
+ if (!ok) {
285
+ console.warn(` [warn] 帧扫描尾部不完整(可能未落盘末帧),使用 ${frames.length} 个完整帧`);
286
+ }
287
+ const chunks = [];
288
+ for (const frame of frames) {
289
+ try {
290
+ chunks.push(zstdDecompressSync(buf.subarray(frame.start, frame.end)).toString("utf8"));
291
+ } catch (e) {
292
+ console.warn(` [warn] 帧 ${frame.start}..${frame.end} 解码失败:${e.message}`);
293
+ }
294
+ }
295
+ return { text: chunks.join(""), empty: false };
296
+ }
297
+
298
+ // ─── 事件解析 ────────────────────────────────────────────────────────────
299
+ function parseEvents(text) {
300
+ const events = [];
301
+ for (const line of text.split("\n")) {
302
+ const t = line.trim();
303
+ if (t === "") continue;
304
+ try {
305
+ const obj = JSON.parse(t);
306
+ if (obj && typeof obj === "object" && typeof obj.type === "string") {
307
+ events.push(obj);
308
+ } else if (obj && typeof obj === "object" && obj.event && typeof obj.event.type === "string") {
309
+ events.push(obj.event);
310
+ }
311
+ } catch { /* 非 JSON 行跳过 */ }
312
+ }
313
+ events.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
314
+ return events;
315
+ }
316
+
317
+ // ─── session header 读取(depth/origin 判断)─────────────────────────────
318
+ function readSessionHeader(file) {
319
+ try {
320
+ const buf = fs.readFileSync(file);
321
+ if (buf.length === 0) return { empty: true };
322
+ const { frames } = scanZstdFrames(buf);
323
+ if (frames.length === 0) return { empty: true };
324
+ const headerText = zstdDecompressSync(buf.subarray(frames[0].start, frames[0].end)).toString("utf8");
325
+ const firstLine = headerText.split("\n")[0];
326
+ const o = JSON.parse(firstLine);
327
+ const h = o.header || o;
328
+ return {
329
+ id: String(h.id || ""),
330
+ delegationDepth: typeof h.delegationDepth === "number" ? h.delegationDepth : 0,
331
+ origin: h.origin,
332
+ parentSession: h.parentSession,
333
+ empty: false,
334
+ };
335
+ } catch {
336
+ return { empty: true };
337
+ }
338
+ }
339
+
340
+ // ─── 文本提取 ────────────────────────────────────────────────────────────
341
+ function flattenText(content) {
342
+ if (typeof content === "string") return content;
343
+ if (Array.isArray(content)) {
344
+ return content.map((b) => {
345
+ if (typeof b === "string") return b;
346
+ if (b && typeof b === "object") {
347
+ if (typeof b.text === "string") return b.text;
348
+ if (typeof b.content === "string") return b.content;
349
+ }
350
+ return "";
351
+ }).join("");
352
+ }
353
+ if (content && typeof content === "object") {
354
+ if (typeof content.text === "string") return content.text;
355
+ }
356
+ return "";
357
+ }
358
+
359
+ // ─── Level 1 清洗:内容噪音(命令执行/系统输出噪音过滤)──────────────────
360
+ function isGarbageContent(c) {
361
+ if (!c) return true;
362
+ if (/^\s*(?:stages|core-plugin-tool|abort-wrappers|deferred-followup|attempt:|openclaw\.|node\.list|ws\s+[⬅➡↔⇄])/i.test(c)) return true;
363
+ if (/^An async command (?:did not run|the user already approved has completed)/i.test(c)) return true;
364
+ if (/^Exec finished \(gateway id=/i.test(c)) return true;
365
+ if (/^Approval required \(id /i.test(c)) return true;
366
+ if (/^Exact completion details:/i.test(c)) return true;
367
+ if (/^Command:\s*```/i.test(c)) return true;
368
+ if (/^Mode: foreground/i.test(c)) return true;
369
+ if (/^Conversation info \(untrusted metadata\):/i.test(c)) return true;
370
+ if (/request:\s*Writable\s*\{\s*_events:\s*\[Object\]/i.test(c)) return true;
371
+ if (/abort-wrappers:\d+ms@\d+ms|deferred-followup-descriptions:\d+ms@\d+ms|attempt:[a-z0-9-]+:\d+ms@\d+ms/i.test(c)) return true;
372
+ if (c.length > 400 && /^[\x00-\x7F\s]+$/.test(c)) {
373
+ const lower = c.toLowerCase();
374
+ let score = 0;
375
+ if (lower.includes("approval required")) score += 3;
376
+ if (lower.includes("gateway id=")) score += 2;
377
+ if (lower.includes("exact completion details")) score += 3;
378
+ if (lower.includes("async command")) score += 2;
379
+ if (lower.includes("exec finished")) score += 2;
380
+ if (lower.includes("only ask the user")) score += 1;
381
+ if (lower.includes("do not run the command again")) score += 2;
382
+ if (score >= 3) return true;
383
+ }
384
+ return false;
385
+ }
386
+
387
+ // ─── Level 1 清洗:凭据模式(secret-scanner 思路)────────────────────────
388
+ const SECRET_PATTERNS = [
389
+ /\b(?:sk|pk|ak|token|secret|api[_-]?key|password|passwd|pwd)\b[=:]\s*['"]?[A-Za-z0-9_\-]{12,}/i,
390
+ /\b(?:sk-|pk-)[A-Za-z0-9]{20,}\b/,
391
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
392
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
393
+ /xox[baprs]-[A-Za-z0-9-]{10,}/,
394
+ ];
395
+
396
+ function hasSecret(c) {
397
+ return SECRET_PATTERNS.some((re) => re.test(c));
398
+ }
399
+
400
+ // ─── 消息提取(核心:user 锚点分组 + 每 turn 取最后一条 assistant)───────
401
+ const INJECTION_KINDS = new Set([
402
+ "plugin", "system", "goal", "agent-instructions", "skill-catalog",
403
+ "subagent-settled", "subagent-report", "hook", "compaction",
404
+ ]);
405
+
406
+ function isRealUserEvent(ev) {
407
+ if (ev.type !== "user/message") return false;
408
+ const kind = ev.data?.source?.kind;
409
+ if (kind === undefined) return true; // 无 source 视为真实用户(兼容旧格式)
410
+ return !INJECTION_KINDS.has(kind);
411
+ }
412
+
413
+ /**
414
+ * 提取对话消息:
415
+ * - 按真实 user/message 锚点分组
416
+ * - 每组只保留最后一条 assistant/message(最终交付回复)
417
+ * - 无 user 配对的 assistant 丢弃
418
+ */
419
+ function extractMessages(events, sinceSeq) {
420
+ const pairs = []; // [{user, assistant, userSeq}]
421
+ let cur = null; // 当前分组
422
+ const dropped = { plugin: 0, garbage: 0, secret: 0, empty: 0, orphan: 0, total: 0 };
423
+
424
+ for (const ev of events) {
425
+ if (sinceSeq !== undefined && (ev.seq ?? 0) <= sinceSeq) continue;
426
+ dropped.total++;
427
+
428
+ if (ev.type === "user/message") {
429
+ if (!isRealUserEvent(ev)) { dropped.plugin++; continue; }
430
+ // 结算上一组
431
+ if (cur !== null) pairs.push(cur);
432
+ const text = flattenText(ev.data?.content).trim();
433
+ cur = {
434
+ user: { role: "user", content: text, seq: ev.seq, ts: ev.data?.timestamp || ev.time },
435
+ assistant: null,
436
+ };
437
+ if (!text || text.length < 2) {
438
+ // 空 user 消息:不成立组(也计入 empty)
439
+ dropped.empty++;
440
+ cur = null;
441
+ }
442
+ } else if (ev.type === "assistant/message") {
443
+ if (cur === null) { dropped.orphan++; continue; } // 无 user 配对的 assistant
444
+ const text = flattenText(ev.data?.message?.content).trim();
445
+ if (!text) { dropped.empty++; continue; }
446
+ if (isGarbageContent(text)) { dropped.garbage++; continue; }
447
+ if (hasSecret(text)) { dropped.secret++; continue; }
448
+ // 只保留最后一条(覆盖式)
449
+ cur.assistant = {
450
+ role: "assistant",
451
+ content: text.length > MAX_CONTENT_CHARS ? text.slice(0, MAX_CONTENT_CHARS) + "…[truncated]" : text,
452
+ seq: ev.seq,
453
+ ts: ev.data?.message?.timestamp || ev.time,
454
+ };
455
+ }
456
+ // 其他事件(tool/边界/chunk)不参与
457
+ }
458
+ if (cur !== null) pairs.push(cur);
459
+
460
+ // 输出:user + 最后一条 assistant(无 assistant 的组也保留 user 单条)
461
+ const msgs = [];
462
+ for (const p of pairs) {
463
+ if (p.user) msgs.push(p.user);
464
+ if (p.assistant) msgs.push(p.assistant);
465
+ }
466
+ return { msgs, dropped, userTurns: pairs.length };
467
+ }
468
+
469
+ // ─── 时间/渠道处理 ───────────────────────────────────────────────────────
470
+ function toDateStr(ts) {
471
+ if (!ts) return null;
472
+ const d = new Date(ts);
473
+ if (isNaN(d.getTime())) return null;
474
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
475
+ }
476
+
477
+ function toTimeStr(ts) {
478
+ if (!ts) return "";
479
+ const d = new Date(ts);
480
+ if (isNaN(d.getTime())) return "";
481
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
482
+ }
483
+
484
+ function channelForDir(dir) {
485
+ for (const [re, ch] of CHANNEL_MAP) {
486
+ if (re.test(dir)) return ch;
487
+ }
488
+ return "";
489
+ }
490
+
491
+ // ─── batch 组装(与 mem0-build-prebatches 同构)──────────────────────────
492
+ function buildBatches(msgs) {
493
+ const batches = [];
494
+ let cur = [], curChars = 0;
495
+ for (const m of msgs) {
496
+ if ((curChars + m.content.length > MAX_CONTENT_CHARS && cur.length > 0) || cur.length >= MAX_MSGS_PER_BATCH) {
497
+ batches.push(cur.slice());
498
+ cur = [];
499
+ curChars = 0;
500
+ }
501
+ cur.push(m);
502
+ curChars += m.content.length;
503
+ }
504
+ if (cur.length > 0) batches.push(cur);
505
+ return batches;
506
+ }
507
+
508
+ // ─── 主流程 ──────────────────────────────────────────────────────────────
509
+ function parseArgs(argv) {
510
+ const get = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
511
+ return {
512
+ help: argv.includes("--help") || argv.includes("-h"),
513
+ session: get("--session"),
514
+ sinceSeq: get("--since-seq"),
515
+ date: get("--date"),
516
+ dryRun: argv.includes("--dry-run"),
517
+ out: get("--out") ?? OUT_ROOT,
518
+ includeSubagents: argv.includes("--include-subagents"),
519
+ includeEmpty: argv.includes("--include-empty"),
520
+ logDir: get("--log-dir"),
521
+ checkpoint: get("--checkpoint"),
522
+ resultFile: get("--result-file"),
523
+ };
524
+ }
525
+
526
+ // ─── checkpoint / result(P2.1:per-session 增量窗口 + 崩溃容忍)──────────
527
+ // checkpoint:`{sessionId: lastSeq}`(每个 session 上次导入到的最大事件 seq)。
528
+ // G1 只**读** checkpoint 做增量窗口、只**写** result 文件(候选结果)——
529
+ // 正式 checkpoint 由调用方(dsh-mem0-import)在导入全部成功后才落盘,
530
+ // 保证「失败不更新 checkpoint」。result 写入用 tmp+rename 原子替换。
531
+
532
+ function loadCheckpoint(file) {
533
+ try {
534
+ if (!fs.existsSync(file)) {
535
+ log("INFO", "checkpoint", `checkpoint 不存在(全量模式): ${file}`);
536
+ return {};
537
+ }
538
+ const obj = JSON.parse(fs.readFileSync(file, "utf8"));
539
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) throw new Error("结构非法");
540
+ const map = {};
541
+ for (const [k, v] of Object.entries(obj)) {
542
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) map[k] = v;
543
+ }
544
+ log("INFO", "checkpoint", `checkpoint 已加载: ${Object.keys(map).length} 个 session`);
545
+ return map;
546
+ } catch (e) {
547
+ log("WARN", "checkpoint", `checkpoint 读取失败(按全量处理,mem0 hash 去重兜底): ${e.message}`);
548
+ return {};
549
+ }
550
+ }
551
+
552
+ function writeResultFile(file, lastSeqMap, batches) {
553
+ if (!file) return;
554
+ const tmp = `${file}.tmp`;
555
+ fs.writeFileSync(tmp, JSON.stringify({ lastSeq: lastSeqMap, batches }, null, 2), "utf8");
556
+ // 写后校验(崩溃容忍:读回可解析才 rename,避免半截文件成为正式 checkpoint)
557
+ const parsed = JSON.parse(fs.readFileSync(tmp, "utf8"));
558
+ if (!parsed || typeof parsed.lastSeq !== "object" || !Array.isArray(parsed.batches)) {
559
+ throw new Error("result 自校验失败");
560
+ }
561
+ fs.renameSync(tmp, file);
562
+ log("INFO", "checkpoint", `result 已写入: ${file}(${Object.keys(lastSeqMap).length} session / ${batches.length} batch)`);
563
+ }
564
+
565
+ const USAGE = `g1-session-parser — DSH 会话日志 → mem0 记忆批次解析器(P0 对话导入)
566
+
567
+ 用法:
568
+ node g1-session-parser.mjs [选项]
569
+
570
+ 选项:
571
+ --session <file> 只解析指定 session 文件(默认扫描 $DSH_HOME/sessions)
572
+ --since-seq <N> 增量:只处理 seq > N 的事件(全局阈值;与 --checkpoint 互斥)
573
+ --checkpoint <file> per-session 增量:读 \`\{sessionId: lastSeq}\` 作各自窗口(推荐)
574
+ --result-file <file> 写本次结果 \`\{lastSeq: \{sessionId: N}, batches: [...]}\`(原子写,
575
+ 供导入命令在全部成功后落盘为正式 checkpoint;失败不更新)
576
+ --date <YYYY-MM-DD> 只输出指定日期的批次(默认今天)
577
+ --out <dir> 批次输出目录(默认 $DSH_HOME/tmp/dsh-memory-g1)
578
+ --log-dir <dir> 日志目录(默认 $DSH_HOME/logs)
579
+ --include-subagents 放宽:收录子代理 session(默认跳过)
580
+ --include-empty 放宽:收录无真实 user 的 session(默认跳过)
581
+ --dry-run 只统计不落盘
582
+ -h, --help 显示本帮助
583
+
584
+ 环境变量:
585
+ DSH_HOME DSH 数据根目录(默认 ~/.dsh)
586
+
587
+ 工作区排除:
588
+ 目录名以 0_ 开头的工作区不入库(写死约定);excluded-workspaces.json
589
+ (脚本同目录)的 excluded 数组按子串匹配补充排除;配置缺失/损坏时
590
+ fail-closed 退出(拒绝无保护运行)。
591
+ `;
592
+
593
+ async function main() {
594
+ const args = parseArgs(process.argv.slice(2));
595
+ if (args.help) {
596
+ console.log(USAGE);
597
+ return;
598
+ }
599
+ const sinceSeq = args.sinceSeq !== undefined ? Number(args.sinceSeq) : undefined;
600
+ const checkpoint = args.checkpoint ? loadCheckpoint(args.checkpoint) : null;
601
+ // per-session 窗口:--since-seq 全局阈值优先;否则用 checkpoint[sessionId](无记录 = 全量)
602
+ const seqFor = (sessionId) => (sinceSeq !== undefined ? sinceSeq : ((checkpoint && checkpoint[sessionId]) ?? 0));
603
+ const targetDate = args.date || new Date().toISOString().slice(0, 10);
604
+ initLogger(targetDate, args.logDir);
605
+ log("INFO", "main", `G1 启动 args=${JSON.stringify({ session: !!args.session, sinceSeq, checkpoint: !!args.checkpoint, date: args.date, dryRun: args.dryRun, out: args.out, includeSubagents: args.includeSubagents, includeEmpty: args.includeEmpty })}`);
606
+
607
+ try {
608
+ // 收集目标 session 文件(Level 0 硬过滤)
609
+ const filtered = { empty: 0, subagent: 0, noUser: 0, workspace: 0 };
610
+ const selected = [];
611
+ let files = [];
612
+ if (args.session) {
613
+ const sk = path.basename(path.dirname(path.dirname(args.session)));
614
+ const excluded = loadExcludedWorkspaces();
615
+ const decoded = decodeProjectKey(sk);
616
+ const record = registryRecordFor(decoded);
617
+ if (isWorkspaceExcluded(sk, decoded, excluded) || isPrefixedWorkspace(sk, decoded, record)) {
618
+ log("ERROR", "filter", `--session 属于排除工作区,拒绝解析: ${sk} (${decoded})`);
619
+ process.exit(1);
620
+ }
621
+ files = [{ file: args.session, dir: path.basename(path.dirname(args.session)) }];
622
+ } else if (fs.existsSync(SESSIONS_ROOT)) {
623
+ const excluded = loadExcludedWorkspaces();
624
+ for (const projectDir of fs.readdirSync(SESSIONS_ROOT)) {
625
+ const pd = path.join(SESSIONS_ROOT, projectDir);
626
+ if (!fs.statSync(pd).isDirectory()) continue;
627
+ if (projectDir === "Archived") continue;
628
+ const decoded = decodeProjectKey(projectDir);
629
+ const prefixCh = prefixedChannels(projectDir, decoded, registryRecordFor(decoded));
630
+ if (isWorkspaceExcluded(projectDir, decoded, excluded) || prefixCh.length > 0) {
631
+ filtered.workspace++;
632
+ const reasons = [];
633
+ if (prefixCh.length > 0) reasons.push(prefixCh.join(";"));
634
+ if (isWorkspaceExcluded(projectDir, decoded, excluded)) reasons.push("排除名单");
635
+ log("INFO", "filter", `工作区排除: ${projectDir} (${decoded}) [${reasons.join(";")}]`);
636
+ continue;
637
+ }
638
+ for (const sessionDir of fs.readdirSync(pd)) {
639
+ const f = path.join(pd, sessionDir, "session.jsonl.zstd");
640
+ if (fs.existsSync(f)) files.push({ file: f, dir: sessionDir });
641
+ }
642
+ }
643
+ }
644
+ if (files.length === 0) {
645
+ log("ERROR", "main", "未找到 session 文件(--session 或默认 sessions 目录)");
646
+ process.exit(1);
647
+ }
648
+ log("INFO", "main", `候选 session 文件: ${files.length}(排除工作区 ${filtered.workspace} 个)`);
649
+
650
+ // Level 0 过滤统计(filtered 已在上方枚举阶段初始化,含 workspace 计数)
651
+
652
+ for (const { file, dir } of files) {
653
+ const header = readSessionHeader(file);
654
+ if (header.empty) { filtered.empty++; log("INFO", "filter", `空壳跳过: ${dir}`); continue; }
655
+ if (!args.includeSubagents && (header.delegationDepth ?? 0) >= 1) {
656
+ filtered.subagent++;
657
+ log("INFO", "filter", `子代理跳过: ${dir} (depth=${header.delegationDepth}, origin=${header.origin})`);
658
+ continue;
659
+ }
660
+ selected.push({ file, dir, header });
661
+ }
662
+
663
+ // 逐 session 解析(先收集,后判断无 user)
664
+ const allByDate = new Map(); // date -> msgs[]
665
+ let totalEvents = 0, totalMsgs = 0, totalUserTurns = 0;
666
+ const dropSummary = { plugin: 0, garbage: 0, secret: 0, empty: 0, orphan: 0, total: 0 };
667
+ const sessionStats = [];
668
+ const lastSeqMap = {}; // sessionId -> 文件末尾 seq(checkpoint 候选)
669
+
670
+ for (const { file, dir, header } of selected) {
671
+ const { text, empty } = decodeSessionLog(file);
672
+ if (empty) { log("WARN", "decode", `解码为空: ${dir}`); continue; }
673
+ const events = parseEvents(text);
674
+ totalEvents += events.length;
675
+
676
+ const { msgs, dropped, userTurns } = extractMessages(events, seqFor(header.id));
677
+ // 记录文件末尾 seq 作 checkpoint 候选(解析过即记录;无新内容也跟进,避免重复解析)
678
+ if (events.length > 0) lastSeqMap[header.id] = events[events.length - 1].seq ?? 0;
679
+ for (const k of Object.keys(dropSummary)) dropSummary[k] += dropped[k];
680
+ totalMsgs += msgs.length;
681
+ totalUserTurns += userTurns;
682
+
683
+ sessionStats.push({ dir, id: header.id, userTurns, msgs: msgs.length, depth: header.delegationDepth });
684
+ log("INFO", "session", `${dir}: ${events.length} 事件 → ${userTurns} turn / ${msgs.length} 消息 (depth=${header.delegationDepth})`);
685
+
686
+ if (userTurns === 0 && !args.includeEmpty) {
687
+ filtered.noUser++;
688
+ log("INFO", "filter", `无真实 user 跳过: ${dir}`);
689
+ continue;
690
+ }
691
+
692
+ const channel = channelForDir(dir);
693
+ for (const m of msgs) {
694
+ const date = toDateStr(m.ts);
695
+ if (!date) continue;
696
+ const time = toTimeStr(m.ts);
697
+ const prefix = `[${date} ${time}${channel ? " " + channel : ""}] `;
698
+ const entry = { role: m.role, content: prefix + m.content };
699
+ if (!allByDate.has(date)) allByDate.set(date, []);
700
+ allByDate.get(date).push(entry);
701
+ }
702
+ }
703
+
704
+ // 日期过滤
705
+ let dates = [...allByDate.keys()].sort();
706
+ if (args.date) dates = dates.filter((d) => d === args.date);
707
+ if (dates.length === 0) {
708
+ log("WARN", "main", "无匹配日期的事件");
709
+ if (!args.dryRun && args.resultFile) writeResultFile(args.resultFile, lastSeqMap, []);
710
+ return;
711
+ }
712
+
713
+ // 统计报告(stdout + log)
714
+ log("INFO", "main", `=== G1 解析统计 ===`);
715
+ log("INFO", "main", `候选 ${files.length} / 过滤: 空壳${filtered.empty} 子代理${filtered.subagent} 无user${filtered.noUser} 工作区${filtered.workspace} / 纳入 ${selected.length - filtered.noUser}`);
716
+ log("INFO", "main", `事件 ${totalEvents} / 清洗丢弃: 注入${dropSummary.plugin} 噪音${dropSummary.garbage} 凭据${dropSummary.secret} 空${dropSummary.empty} 孤儿${dropSummary.orphan}`);
717
+ log("INFO", "main", `有效消息 ${totalMsgs} (${totalUserTurns} turn)`);
718
+ for (const d of dates) {
719
+ log("INFO", "main", ` ${d}: ${allByDate.get(d).length} 条消息`);
720
+ }
721
+
722
+ if (args.dryRun) {
723
+ log("INFO", "main", "[dry-run] 不落盘。");
724
+ return;
725
+ }
726
+
727
+ // 落盘:staging 目录 → 自校验 → 拷贝到正式目录(续号,不覆盖已有 P0)
728
+ let totalBatches = 0;
729
+ const copied = [];
730
+ const copiedBatches = []; // 带日期前缀的相对路径(result-file 用)
731
+ for (const d of dates) {
732
+ const msgs = allByDate.get(d);
733
+ const batches = buildBatches(msgs);
734
+ const targetDir = path.join(args.out, d);
735
+ const stagingDir = path.join(stagingRootFor(args.out), d);
736
+ fs.mkdirSync(stagingDir, { recursive: true });
737
+
738
+ // 续号:正式目录已有 P0_*.json 的最大编号 + 1
739
+ const startNum = nextP0Number(targetDir);
740
+ log("INFO", "write", `${d}: 续号起点 P0_${String(startNum).padStart(3, "0")}(正式目录 ${fs.existsSync(targetDir) ? fs.readdirSync(targetDir).filter(f => /^P0_/.test(f)).length : 0} 个已有 P0)`);
741
+
742
+ // 写 staging
743
+ const stagingFiles = [];
744
+ for (let i = 0; i < batches.length; i++) {
745
+ const name = `P0_${String(startNum + i).padStart(3, "0")}.json`;
746
+ fs.writeFileSync(path.join(stagingDir, name), JSON.stringify(batches[i], null, 2), "utf8");
747
+ stagingFiles.push(name);
748
+ }
749
+
750
+ // 自校验:文件数 > 0 且 JSON 可解析
751
+ let ok = stagingFiles.length > 0;
752
+ for (const name of stagingFiles) {
753
+ try {
754
+ const parsed = JSON.parse(fs.readFileSync(path.join(stagingDir, name), "utf8"));
755
+ if (!Array.isArray(parsed)) throw new Error("不是数组");
756
+ } catch (e) {
757
+ ok = false;
758
+ log("ERROR", "validate", `staging 校验失败 ${name}: ${e.message}`);
759
+ }
760
+ }
761
+
762
+ if (!ok) {
763
+ log("ERROR", "write", `${d}: staging 校验失败,不拷贝到正式目录(staging 保留 ${stagingDir} 供排查)`);
764
+ continue;
765
+ }
766
+
767
+ // 拷贝到正式目录
768
+ fs.mkdirSync(targetDir, { recursive: true });
769
+ for (const name of stagingFiles) {
770
+ fs.copyFileSync(path.join(stagingDir, name), path.join(targetDir, name));
771
+ copied.push(name);
772
+ copiedBatches.push(`${d}/${name}`);
773
+ totalBatches++;
774
+ }
775
+ // G4: 写 DSH batch 标记 sidecar(daemon 据此给 memory.add 加 source="DSH")
776
+ // 追加模式:合并本次与已有 sidecar(补跑时保留历史 DSH batch 标记)
777
+ try {
778
+ const sidecarPath = path.join(targetDir, ".dsh-batch-list.json");
779
+ let dshList = [];
780
+ if (fs.existsSync(sidecarPath)) {
781
+ try { dshList = JSON.parse(fs.readFileSync(sidecarPath, "utf8")); } catch { dshList = []; }
782
+ }
783
+ for (const name of stagingFiles) {
784
+ if (!dshList.includes(name)) dshList.push(name);
785
+ }
786
+ fs.writeFileSync(sidecarPath, JSON.stringify(dshList, null, 2), "utf8");
787
+ log("INFO", "write", `${d}: sidecar 已更新(${dshList.length} 个 DSH batch)`);
788
+ } catch (e) {
789
+ log("WARN", "write", `sidecar 写入失败(不影响 batch): ${e.message}`);
790
+ }
791
+ log("INFO", "write", `${d}: 拷贝 ${stagingFiles.length} 个 batch → ${targetDir}`);
792
+ }
793
+ // 写 result(导入候选;dry-run 不写)
794
+ if (!args.dryRun && args.resultFile) writeResultFile(args.resultFile, lastSeqMap, copiedBatches);
795
+ log("INFO", "main", `完成: ${totalBatches} 个 batch 已写入 ${args.out}/(${copied.join(", ")})`);
796
+ } catch (e) {
797
+ log("ERROR", "main", `G1 执行失败: ${e.stack || e.message}`);
798
+ process.exit(1);
799
+ } finally {
800
+ closeLogger();
801
+ }
802
+ }
803
+
804
+ // 未捕获异常落盘(WTS 无 stdout 重定向,失败必须可追查)
805
+ process.on("uncaughtException", (err) => {
806
+ try {
807
+ const logDir = DEFAULT_LOG_DIR;
808
+ fs.mkdirSync(logDir, { recursive: true });
809
+ fs.appendFileSync(path.join(logDir, `g1-uncaught-${new Date().toISOString().slice(0, 10)}.log`),
810
+ `[${new Date().toISOString()}] UNCAUGHT in g1-session-parser.mjs: ${err.stack || err}\n---\n`, "utf8");
811
+ } catch {}
812
+ console.error("[g1] 未捕获异常,详情已落盘", err);
813
+ process.exit(1);
814
+ });
815
+
816
+ main().catch((e) => { console.error(e); process.exit(1); });