dsh-mem0-memory 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ### 0.1.6(2026-10-29)
9
+
10
+ - **跟随 DSH 0.2.x 的 v4 会话格式与工作区身份判定**,四处修复(前两处是用户可感知的静默失效):
11
+ 1. **会话文件定位(G1)**:旧实现只认 `session.jsonl.zstd`;DSH 0.2.x 写 `session.v4.jsonl.zstd` → 升级后**所有新会话对解析器不可见**,DSH 侧素材静默断流。改为「同一会话目录取 mtime 最新的 `session*.jsonl(.zstd)`,同 mtime 取格式版本高的」。
12
+ 2. **注入过滤(G1)**:v4 把消息来源从 `{kind:"plugin",plugin:"x"}` 改成生产者自有 kind(`plugin:<name>` / `time-context` / `user-approval` / `agent-instructions` …),旧黑名单全部漏网 → 插件自己注入的召回块会被当作真实用户消息**回灌记忆库**(自我污染循环)。改为**白名单**:只有 `kind:"user"`(或无 source 的历史消息)算真实用户,其余丢弃并计数,未知 kind 打 WARN。
13
+ 3. **工作区身份判定(G1)**:`projectKey` 是 cwd 的有损编码(`: / \`→`-`,但不转义原有的 `-`),含连字符的路径会被切碎 → 台账精确匹配与 `0_` 前缀双失效。改为读**会话 header 的 `cwd`** 作权威路径;解码只作展示与兜底;**移除 basename 兜底**(连字符下只剩碎片,可能误命中别的区 → 误排除)。
14
+ 4. **召回侧工作区排除(插件)**:原读 `agent.session.cwd`——该属性在 DSH 中**不存在**,排除判定恒为 false(自 0.1.3 上线起从未生效)。改为 `session.header.cwd` → 兜底 `ctx.workingDirectory.get(session)`;`inject` 增加 `workingDirectory`。
15
+ - **工作区排除新增第三个渠道:台账标题**。`$DSH_HOME/storages/workspace.json` 的 `title` 以 `0_` 开头即排除——支持「只在 DSH 界面里把工作区改名成 `0_`、目录名不变」的用法(插件侧读台账并按 mtime 缓存,缺失时 fail-open)。
16
+ - 兼容性:以上改动对 0.1.x 的会话格式与台账结构向后兼容(无 source 的历史消息仍视为真实用户;旧文件名仍在候选列表里)。
17
+ - 影响面:`scripts/g1-session-parser.mjs`、`lib/index.js`、`package.json`;server / 导入命令未改动。
18
+ - 验证:v4 会话识别(候选文件数上升、窗口内素材由 0 → 非 0);同一 v4 会话旧版 26 turn/43 消息 → 新版 17 turn/34 消息(滤掉注入);落盘批次零注入痕迹;插件假 ctx 六用例(目录名 `0_` / 台账标题 `0_` / 名单 / workingDirectory 兜底 / 普通区仍注入)全过。
19
+
20
+ ### 0.1.5(2026-10-18)
21
+
22
+ - **适配 DSH 0.2.x(0.2.1-rc.2 实测)**,两处改动,缺一不可:
23
+ 1. **peer 范围放宽**:`@deepseek-ai/dsh-agent-loop` 由 `^0.1.0-rc.6`(= `>=0.1.0-rc.6 <0.2.0`)改为 **`^0.1.0-rc.6 || ^0.2.1-rc.2`**。0.2.1 起底座新增「插件兼容性硬校验」,旧范围会被判为不兼容并**拒绝安装 + 回滚**(`installation rejected: Plugin dsh-mem0-memory@0.1.4 is incompatible with dsh 0.2.1-rc.2`)。
24
+ - 选**有界区间**而非 `*`/`>=`:本插件会向会话写入事件,声明「任何版本都支持」会在 DSH 大版本再变时静默踩雷(参见 2026-08-19 会话损坏事故)。0.3.x 起需重新验证后再放宽。
25
+ - 覆盖:0.1.5-rc.3(旧线)✅、0.2.1-rc.2(新线)✅、0.2.x 后续 rc/补丁 ✅。
26
+ 2. **注入消息的 `source.kind` 改写**:`{ kind: "plugin", plugin: "dsh-mem0-memory", form: "recall" }` → **`{ kind: "plugin:dsh-mem0-memory", form: "recall" }`**。
27
+ - 根因:0.2.x 引入 session format **v4**,其 `source()` 校验**明确禁止 `kind:"plugin"`**(要求生产者自有 kind,`plugin:<name>` 即 V3→V4 迁移器对未知插件的标准映射)。若只放宽 peer 不改这里,结果是「装得上、但每轮对话在注入后报 `format v4 message requires a producer-owned source kind` 而失败」——比装不上更糟。
28
+ - 兼容性:0.1.x 的写入门槛只要求「kind 为非空字符串」,`kind:"plugin"` 的强校验仅针对 `system/message`,`user/message` 不受限 → 同一份代码在 0.1.x 与 0.2.x 上都能跑,无需按底座版本分支。
29
+ - 影响面:`lib/index.js`(注入点一处)、`package.json`(版本 + peer);server / G1 解析器 / 导入命令未改动。
30
+ - 验证:0.2.1-rc.2 真实环境实跑(安装通过、注入链完整、会话可关闭重开、转录 v4 事件形态正确)。
31
+
8
32
  ### 0.1.4(2026-08-23)
9
33
 
10
34
  - **server 自动拉起(autoStartServer)**:DSH 启动时插件探测 mem0 server 的 `/health`(3s 超时)——未在跑则自动 spawn 一次(`detached` 子进程,不随 DSH 退出);已在跑则不动。用户装了插件 = 期望「与平台同步启动」,忘记手动开 server 也能工作。
package/README.md CHANGED
@@ -152,6 +152,10 @@ python scripts/mem0-server.py --port 18200 # 启动服务
152
152
  > 重启后,**跑一次首次导入(见下节)**,历史会话就进记忆库了——之后自动增量,不用再管。
153
153
  >
154
154
  > **peer 依赖警告可忽略**:安装时 pnpm 可能提示 `missing peer @deepseek-ai/cordis` / `@deepseek-ai/dsh-agent-loop`——这是声明性警告,插件运行时会从 DSH 的 bundle 安装处解析这些依赖,无需处理。
155
+ >
156
+ > **兼容的 DSH 版本**:`dsh-agent-loop` peer 范围 = `^0.1.0-rc.6 || ^0.2.1-rc.2`(0.1.5-rc.3 与 0.2.1-rc.2 均已验证)。DSH 0.2.1 起底座会做插件兼容性硬校验,版本号不在范围内会被**拒绝安装**;若你用的是更晚的大版本(0.3.x 起),请先确认插件已跟进再装。
157
+ >
158
+ > **DSH 0.2.x 的会话格式**:0.2.x 起会话转录改名为 `session.v4.jsonl.zstd`(旧名 `session.jsonl.zstd` 仍兼容),消息来源字段由 `{kind:"plugin",plugin:"x"}` 改为生产者自有 kind。本版(0.1.6+)同时支持两种格式;**0.1.5 及更早版本在 0.2.x 上会「看不到升级后的新会话」并且「把召回块当用户消息回灌」**,请勿在 0.2.x 上使用旧版。工作区排除现在支持三种标记:目录名 `0_` 开头、`excludeWorkspaces` 名单、**以及在 DSH 界面里把工作区改名成 `0_` 开头**。
155
159
 
156
160
  ## 首次导入:把你的历史会话装进记忆库
157
161
 
package/lib/index.js CHANGED
@@ -208,16 +208,77 @@ function extractLastUserText(messages) {
208
208
 
209
209
  // ─── 插件入口 ────────────────────────────────────────────────────────────
210
210
  export const name = "dsh-mem0-memory";
211
- export const inject = ["agents"];
211
+ // 2026-10-29:新增 workingDirectory(官方服务,同步无 IO)作为 cwd 兜底。
212
+ // 只用 agents 时拿不到会话目录(session.cwd 不存在,见 pre-step 内注释)。
213
+ export const inject = ["agents", "workingDirectory"];
212
214
 
213
- // ─── 工作区排除(2026-08-22,与 G1 判定一致)────────────────────────────
215
+ // ─── 工作区排除(2026-08-22 上线;2026-10-29 对齐 G1 三渠道)─────────────
214
216
  // 排除工作区的会话:不召回也不注入(该区内容本就不入库,召回只会带来延时开销)。
215
- // 判定:① cwd 路径任一段以 `0_` 开头(写死约定,与 G1 EXCLUDE_PREFIX 一致)
216
- // ② config.excludeWorkspaces 名单子串匹配(对应 G1 excluded-workspaces.json)
217
- function isExcludedWorkspace(cwd, list) {
218
- if (!cwd) return false;
219
- if (String(cwd).split(/[\\/]/).some((s) => s.startsWith("0_"))) return true;
220
- return (list ?? []).some((e) => e && String(cwd).includes(String(e)));
217
+ // 判定(与 G1 对齐,任一命中即排除):
218
+ // ① cwd 路径任一段以 `0_` 开头(目录名的写死约定,与 G1 EXCLUDE_PREFIX 一致)
219
+ // ② config.excludeWorkspaces 名单子串匹配(对应 G1 excluded-workspaces.json)
220
+ // ③ **台账标题**以 `0_` 开头($DSH_HOME/storages/workspace.json 的 title——
221
+ // 只在 UI 里改名、目录名不变的场景下,①② 都命不中,只有这条能拦住)
222
+ const EXCLUDE_PREFIX = "0_";
223
+
224
+ function normalizeWsPath(p) {
225
+ return String(p).replace(/[\\/]+$/, "").toLowerCase();
226
+ }
227
+
228
+ // 台账缓存:按文件 mtime 失效,避免每轮都读盘(读一次几百字节,成本可忽略)
229
+ const _ledgerCache = { mtimeMs: -1, map: new Map() };
230
+
231
+ async function loadWorkspaceLedger() {
232
+ try {
233
+ const fs = await import("node:fs");
234
+ const os = await import("node:os");
235
+ const path = await import("node:path");
236
+ const home = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
237
+ const file = path.join(home, "storages", "workspace.json");
238
+ const st = fs.statSync(file);
239
+ if (st.mtimeMs === _ledgerCache.mtimeMs) return _ledgerCache.map;
240
+ const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
241
+ const table = cfg?.tables?.workspaces ?? {};
242
+ const map = new Map();
243
+ for (const rec of Object.values(table)) {
244
+ if (!rec || typeof rec.path !== "string" || typeof rec.title !== "string") continue;
245
+ map.set(normalizeWsPath(rec.path), rec.title);
246
+ }
247
+ _ledgerCache.mtimeMs = st.mtimeMs;
248
+ _ledgerCache.map = map;
249
+ return map;
250
+ } catch {
251
+ return _ledgerCache.map; // 台账缺失/损坏 → fail-open(不影响召回主流程)
252
+ }
253
+ }
254
+
255
+ /** 按 cwd 查台账标题:先精确匹配,再取「路径是 cwd 前缀」的最长一条(兼容子目录会话) */
256
+ function ledgerTitleFor(cwd, ledger) {
257
+ if (!ledger || ledger.size === 0 || !cwd) return "";
258
+ const key = normalizeWsPath(cwd);
259
+ if (ledger.has(key)) return ledger.get(key);
260
+ let bestLen = -1, bestTitle = "";
261
+ for (const [p, title] of ledger) {
262
+ if (key === p || key.startsWith(p + "\\") || key.startsWith(p + "/")) {
263
+ if (p.length > bestLen) { bestLen = p.length; bestTitle = title; }
264
+ }
265
+ }
266
+ return bestTitle;
267
+ }
268
+
269
+ /** @returns {{excluded:boolean, reason:string}} —— reason 仅用于日志/探针 */
270
+ async function exclusionVerdict(cwd, list) {
271
+ if (!cwd) return { excluded: false, reason: "" };
272
+ if (String(cwd).split(/[\\/]/).some((s) => s.startsWith(EXCLUDE_PREFIX))) {
273
+ return { excluded: true, reason: "0_ 前缀(目录名)" };
274
+ }
275
+ const hit = (list ?? []).find((e) => e && String(cwd).includes(String(e)));
276
+ if (hit) return { excluded: true, reason: `名单命中「${hit}」` };
277
+ const title = ledgerTitleFor(cwd, await loadWorkspaceLedger());
278
+ if (title && String(title).startsWith(EXCLUDE_PREFIX)) {
279
+ return { excluded: true, reason: `0_ 前缀(台账标题「${title}」)` };
280
+ }
281
+ return { excluded: false, reason: "" };
221
282
  }
222
283
 
223
284
  // ─── server ensure(2026-08-23):DSH 启动时确保 mem0 server 在跑 ─────────
@@ -319,17 +380,35 @@ export async function apply(ctx, config = {}) {
319
380
 
320
381
  if (decision.kind !== "enter" || !Array.isArray(decision.messages)) return decision;
321
382
 
322
- // 工作区排除(2026-08-22):当前会话 cwd 属于排除工作区 → 跳过召回(不注入)。
323
- // 判定与 G1 一致(0_ 前缀 + excludeWorkspaces 名单子串);拿不到 cwd 则不排除。
383
+ // 工作区排除(2026-08-22 上线;2026-10-29 修复取值):
384
+ // 之前读 `agent.session.cwd` —— 该属性在 DSH 里**不存在**,cwd 恒为 "",
385
+ // `isExcludedWorkspace()` 首行 `if (!cwd) return false` 使排除从未生效(fail-open)。
386
+ // 现改为两级取法(都来自官方 API):① session.header.cwd(会话创建时的项目目录,稳定)
387
+ // ② ctx.workingDirectory.get(session)(当前目录,跟随 cd)
324
388
  let cwd = "";
389
+ let cwdSource = "unknown";
325
390
  try {
326
391
  const agent = ctx.agents?.requireInitiator?.();
327
- cwd = agent?.session?.cwd ?? "";
328
- } catch (e) {}
329
- await probe(`workspace cwd=${cwd || "unknown"}`);
330
- if (isExcludedWorkspace(cwd, cfg.excludeWorkspaces)) {
331
- ctx.logger?.info(`[mem0-memory] workspace excluded (skip recall): ${cwd || "(unknown)"}`);
332
- await probe("workspace excluded, skip recall");
392
+ const session = agent?.session;
393
+ const headerCwd = session?.header?.cwd;
394
+ if (typeof headerCwd === "string" && headerCwd.trim() !== "") {
395
+ cwd = headerCwd;
396
+ cwdSource = "header.cwd";
397
+ } else if (session) {
398
+ const current = ctx.workingDirectory?.get?.(session);
399
+ if (typeof current === "string" && current.trim() !== "") {
400
+ cwd = current;
401
+ cwdSource = "workingDirectory";
402
+ }
403
+ }
404
+ } catch (e) {
405
+ await probe(`workspace cwd 取值异常: ${e.message}`);
406
+ }
407
+ await probe(`workspace cwd=${cwd || "unknown"} (source=${cwdSource})`);
408
+ const verdict = await exclusionVerdict(cwd, cfg.excludeWorkspaces);
409
+ if (verdict.excluded) {
410
+ ctx.logger?.info(`[mem0-memory] workspace excluded (skip recall): ${cwd || "(unknown)"} ← ${verdict.reason}`);
411
+ await probe(`workspace excluded, skip recall (${verdict.reason})`);
333
412
  return decision;
334
413
  }
335
414
 
@@ -350,13 +429,21 @@ export async function apply(ctx, config = {}) {
350
429
  // 注入:在最后一条 claimed 消息之后插入记忆块。
351
430
  // 必须带消息 id 且 content 用文本块数组 —— DSH 的 user/message 事件校验要求
352
431
  // message.id 为非空字符串、content 为块数组,缺失会把整个 session 持久化日志判为损坏。
432
+ //
433
+ // source.kind(2026-10-18 适配 DSH 0.2.x / session format v4):
434
+ // 0.2.x 的 v4 格式禁止 `kind:"plugin"` 这个 catch-all 包装
435
+ // (dsh-session-format-v3-to-v4 的 source() 校验:kind 必须非空且 ≠ "plugin"),
436
+ // 要求生产者声明自有 kind;V3→V4 迁移器对未知插件的映射结果就是 `plugin:<name>`,
437
+ // 此处直接用同一命名,保证新写记录与迁移后的历史记录同形。
438
+ // 兼容性:0.1.x 的写入门槛只要求「kind 是非空字符串」,`kind:"plugin"` 的强校验
439
+ // 仅针对 system/message,user/message 不受限制 → 该写法 0.1.x 与 0.2.x 同时合法。
353
440
  const lastClaimedIndex = decision.messages.findLastIndex((message) => messages.includes(message));
354
441
  await probe(`lastClaimedIndex=${lastClaimedIndex}`);
355
442
  const injected = {
356
443
  role: "user",
357
444
  id: randomUUID(),
358
445
  content: [{ type: "text", text: result.block }],
359
- source: { kind: "plugin", plugin: "dsh-mem0-memory", form: "recall" },
446
+ source: { kind: "plugin:dsh-mem0-memory", form: "recall" },
360
447
  };
361
448
  const finalMessages = decision.messages.toSpliced(lastClaimedIndex + 1, 0, injected);
362
449
  await probe(`injected! finalMessages=${finalMessages.length}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mem0-memory",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Thin bridge: recall Mem0 memories into DSH agent context via agent/pre-step injection.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.1",
35
- "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6"
35
+ "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6 || ^0.2.1-rc.2"
36
36
  },
37
37
  "license": "MIT",
38
38
  "repository": {
@@ -6,7 +6,9 @@
6
6
  * DSH session(原始日志)→ G1 解析器 → 增量窗口 → mem0(唯一事实源)。
7
7
  * 本脚本是 G1 解析器主实现,负责把 DSH 的对话日志翻译成记忆批次。
8
8
  *
9
- * 【输入】 ~/.dsh/sessions/<projectKey>/<sessionId>/session.jsonl.zstd
9
+ * 【输入】 ~/.dsh/sessions/<projectKey>/<sessionId>/session*.jsonl.zstd
10
+ * (0.1.x 写 session.jsonl.zstd;0.2.x 起写 session.v4.jsonl.zstd,
11
+ * 同目录取 mtime 最新者,见 resolveSessionFile)
10
12
  * (zstd 帧容器:header 帧 + append 帧;事件 JSONL)
11
13
  * 【输出】 与 mem0 对话批次同构的数组 batch:
12
14
  * <out>/<YYYY-MM-DD>/P0_NNN.json
@@ -70,6 +72,33 @@ const EXCLUDED_WORKSPACES_FILE = path.join(SCRIPT_DIR, "excluded-workspaces.json
70
72
  /** 不入库的工作区命名前缀(写死约定:未来新建不入库的工作区以此命名) */
71
73
  const EXCLUDE_PREFIX = "0_";
72
74
 
75
+ // ─── 会话文件定位(2026-10-29 修复:DSH 0.2.x 写 session.v4.jsonl.zstd)────
76
+ // 旧实现只认 `session.jsonl.zstd`;DSH 0.2.x 起写 `session.v4.jsonl.zstd`
77
+ // (升级器还会为旧会话保留 v3/legacy 副本)——只认旧名会让升级后所有新会话
78
+ // 对解析器不可见(DSH 侧素材静默断流)。
79
+ // 规则:同一会话目录内取 mtime 最新的 session*.jsonl(.zstd);mtime 相同取格式版本高的。
80
+ const SESSION_FILE_PATTERN = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/;
81
+
82
+ /** 一个会话目录里应解析的转录文件;没有则返回 null。 */
83
+ function resolveSessionFile(sessionDir) {
84
+ let names;
85
+ try { names = fs.readdirSync(sessionDir); } catch { return null; }
86
+ const candidates = [];
87
+ for (const name of names) {
88
+ const m = SESSION_FILE_PATTERN.exec(name);
89
+ if (!m) continue;
90
+ const full = path.join(sessionDir, name);
91
+ try {
92
+ const st = fs.statSync(full);
93
+ if (!st.isFile()) continue;
94
+ candidates.push({ full, mtime: st.mtimeMs, rank: m[1] ? Number(m[1]) : 0 });
95
+ } catch { /* 读写竞态跳过 */ }
96
+ }
97
+ if (candidates.length === 0) return null;
98
+ candidates.sort((a, b) => (b.mtime - a.mtime) || (b.rank - a.rank));
99
+ return candidates[0].full;
100
+ }
101
+
73
102
  function loadExcludedWorkspaces() {
74
103
  if (!fs.existsSync(EXCLUDED_WORKSPACES_FILE)) {
75
104
  throw new Error(`工作区排除配置文件缺失: ${EXCLUDED_WORKSPACES_FILE}(fail-closed,拒绝无保护运行)`);
@@ -98,26 +127,34 @@ function decodeProjectKey(key) {
98
127
  return decoded.split("-").filter(Boolean).join("\\");
99
128
  }
100
129
 
101
- function isWorkspaceExcluded(projectKey, decoded, excluded) {
102
- return excluded.some((e) => projectKey.includes(e) || decoded.includes(e));
130
+ /** 名单匹配:原始 projectKey / 解码路径 / 真实路径(header.cwd,2026-10-29 新增)任一命中即排除。
131
+ * @param realPath 会话 header.cwd 给出的绝对路径(权威);缺失时传 null/undefined 只按前两者判 */
132
+ function isWorkspaceExcluded(projectKey, decoded, excluded, realPath) {
133
+ const real = realPath ? String(realPath) : "";
134
+ return excluded.some((e) => projectKey.includes(e) || decoded.includes(e) || (real !== "" && real.includes(e)));
103
135
  }
104
136
 
105
137
  /** 前缀规则(写死约定):不入库的工作区 = 名称以 `0_` 开头。名称有两处
106
138
  * 载体:①目录名(= 创建时的默认 title,路径派生 projectKey);②台账标题
107
139
  * (~/.dsh/storages/workspace.json 的 title,UI 可改名,不影响磁盘目录)。
108
- * 三个命中渠道:解码路径任一段 / 原始 projectKey 任一段 / 台账标题,
109
- * 任一命中即排除。
140
+ * 四个命中渠道:真实路径任一段(header.cwd)/ 解码路径任一段 / 原始 projectKey
141
+ * 任一段 / 台账标题,任一命中即排除。
142
+ * 2026-10-29:新增 cwd 渠道——`decodeProjectKey()` 把 `-` 当分隔符,含连字符的
143
+ * 路径会被切碎(OKR-DSH-Trial-20261024 → OKR\DSH\Trial\20261024),解出的路径
144
+ * 既查不到台账(标题渠道失效)也不含 `0_`(路径渠道失效)。
110
145
  * @returns 命中渠道数组(空数组 = 未命中) */
111
- function prefixedChannels(projectKey, decoded, record) {
146
+ function prefixedChannels(projectKey, decoded, record, realPath) {
112
147
  const ch = [];
113
- if (decoded.split("\\").some((s) => s.startsWith(EXCLUDE_PREFIX))) ch.push("前缀0_(路径)");
148
+ const pathSegs = String(realPath || decoded).split(/[\\/]/).filter(Boolean);
149
+ if (pathSegs.some((s) => s.startsWith(EXCLUDE_PREFIX))) ch.push(realPath ? "前缀0_(cwd)" : "前缀0_(路径)");
150
+ if (realPath && decoded.split("\\").some((s) => s.startsWith(EXCLUDE_PREFIX))) ch.push("前缀0_(路径)");
114
151
  if (projectKey.split("-").some((s) => s.startsWith(EXCLUDE_PREFIX))) ch.push("前缀0_(projectKey)");
115
152
  if (record && String(record.title).startsWith(EXCLUDE_PREFIX)) ch.push("前缀0_(标题)");
116
153
  return ch;
117
154
  }
118
155
 
119
- function isPrefixedWorkspace(projectKey, decoded, record) {
120
- return prefixedChannels(projectKey, decoded, record).length > 0;
156
+ function isPrefixedWorkspace(projectKey, decoded, record, realPath) {
157
+ return prefixedChannels(projectKey, decoded, record, realPath).length > 0;
121
158
  }
122
159
 
123
160
  // ─── DSH 工作区台账(名称的权威载体)─────────────────────────────────────
@@ -150,18 +187,17 @@ function loadWorkspaceRegistry() {
150
187
  return _workspaceRegistry;
151
188
  }
152
189
 
153
- /** 由解码后的工作区路径查台账记录;basename 兜底(路径大小写/尾斜杠差异) */
190
+ /** 由 header.cwd 给出的真实路径精确查台账(权威通道,无解码歧义) */
191
+ function registryRecordForPath(realPath) {
192
+ if (!realPath) return null;
193
+ return loadWorkspaceRegistry().get(normalizeWsPath(realPath)) ?? null;
194
+ }
195
+
196
+ /** 由解码后的工作区路径查台账记录(仅 header.cwd 缺失时的兜底)。
197
+ * 2026-10-29 移除 basename 兜底:连字符路径下解码只剩碎片(如 `20261028`),
198
+ * basename 兜底可能误命中别的工作区标题 → 误排除,比误入库更难发现。 */
154
199
  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;
200
+ return loadWorkspaceRegistry().get(normalizeWsPath(decoded)) ?? null;
165
201
  }
166
202
 
167
203
  // ─── Logger(复杂管线工程守则:必须有足够追查问题根因的 log)─────────────
@@ -314,7 +350,10 @@ function parseEvents(text) {
314
350
  return events;
315
351
  }
316
352
 
317
- // ─── session header 读取(depth/origin 判断)─────────────────────────────
353
+ // ─── session header 读取(depth/origin/cwd 判断)─────────────────────────
354
+ // cwd:DSH 写入 header 的绝对工作目录(SessionHeader.cwd,「会话创建时的目录」)。
355
+ // 2026-10-29 起作为工作区身份判定的权威来源——projectKey 是路径的有损编码,
356
+ // 含连字符时无法还原(见 prefixedChannels 注释)。
318
357
  function readSessionHeader(file) {
319
358
  try {
320
359
  const buf = fs.readFileSync(file);
@@ -330,6 +369,8 @@ function readSessionHeader(file) {
330
369
  delegationDepth: typeof h.delegationDepth === "number" ? h.delegationDepth : 0,
331
370
  origin: h.origin,
332
371
  parentSession: h.parentSession,
372
+ cwd: typeof h.cwd === "string" && h.cwd.trim() !== "" ? h.cwd : undefined,
373
+ version: typeof h.version === "number" ? h.version : undefined,
333
374
  empty: false,
334
375
  };
335
376
  } catch {
@@ -337,6 +378,16 @@ function readSessionHeader(file) {
337
378
  }
338
379
  }
339
380
 
381
+ /** 一个工作区目录里任一会话 header 给出的 cwd(绝对路径,权威)。
382
+ * 只读各会话文件的第一帧,成本远低于全量解析;全部缺失则返回 undefined。 */
383
+ function firstHeaderCwd(sessionFiles) {
384
+ for (const { file } of sessionFiles) {
385
+ const h = readSessionHeader(file);
386
+ if (!h.empty && h.cwd) return h.cwd;
387
+ }
388
+ return undefined;
389
+ }
390
+
340
391
  // ─── 文本提取 ────────────────────────────────────────────────────────────
341
392
  function flattenText(content) {
342
393
  if (typeof content === "string") return content;
@@ -398,16 +449,39 @@ function hasSecret(c) {
398
449
  }
399
450
 
400
451
  // ─── 消息提取(核心:user 锚点分组 + 每 turn 取最后一条 assistant)───────
401
- const INJECTION_KINDS = new Set([
402
- "plugin", "system", "goal", "agent-instructions", "skill-catalog",
403
- "subagent-settled", "subagent-report", "hook", "compaction",
452
+ // 2026-10-29 修复(v4 源类型改造):
453
+ // DSH 0.2.x 的 session format v4 把 source 从 `{kind:"plugin", plugin:"x"}`
454
+ // 改成「生产者自有 kind」:插件注入 = `plugin:<name>`,系统注入 = `time-context`
455
+ // / `agent-instructions` / `skill-catalog` / `user-approval` / `runtime-context`
456
+ // / `subagent-*` / `compact-checkpoint` / `tool-*` / `goal` / `model-selection` …
457
+ // 旧实现是黑名单,v4 新 kind 全部漏网 —— 实测 36 个 v4 会话里插件自己的注入块
458
+ // (`plugin:dsh-mem0-memory` / `plugin:dsh-mem0-bridge`)会被当成真实用户消息回灌
459
+ // 记忆库(自我污染循环)。改为**白名单**:只有 `kind:"user"` 与无 source 的历史
460
+ // 消息算真实用户;其余一律视为注入并计数,未知 kind 打 WARN 便于发现新渠道。
461
+ const REAL_USER_KINDS = new Set(["user"]);
462
+ const KNOWN_INJECTION_KINDS = new Set([
463
+ "plugin", // 0.1.x 的 catch-all 包装(v4 已废止,历史会话里仍会出现)
464
+ "system", "developer", "goal", "agent-instructions", "skill-catalog",
465
+ "subagent-settled", "subagent-report", "hook", "compaction", "compact-checkpoint",
466
+ "time-context", "runtime-context", "tmux-context", "model-selection", "plan-mode",
467
+ "user-approval", "repeat-tool-reminder", "tool-jobs", "tool-goal", "tool-cordis",
468
+ "agent-message", "team-message", "coordinator", "schedule", "webhook",
469
+ "session-reference", "system-prompt", "cordis-host-runner", "dsh-session-title-llm",
470
+ "ptc-mode", "compact-basic",
404
471
  ]);
472
+ const _warnedKinds = new Set();
405
473
 
406
474
  function isRealUserEvent(ev) {
407
475
  if (ev.type !== "user/message") return false;
408
476
  const kind = ev.data?.source?.kind;
409
- if (kind === undefined) return true; // 无 source 视为真实用户(兼容旧格式)
410
- return !INJECTION_KINDS.has(kind);
477
+ if (kind === undefined) return true; // 无 source:兼容 0.1.x 之前的历史格式
478
+ if (REAL_USER_KINDS.has(kind)) return true;
479
+ const isPluginProducer = typeof kind === "string" && kind.startsWith("plugin:");
480
+ if (!isPluginProducer && !KNOWN_INJECTION_KINDS.has(kind) && !_warnedKinds.has(kind)) {
481
+ _warnedKinds.add(kind);
482
+ log("WARN", "filter", `未知 source.kind=${kind}:按注入丢弃(若这是真实用户消息,请把该 kind 加进 REAL_USER_KINDS)`);
483
+ }
484
+ return false;
411
485
  }
412
486
 
413
487
  /**
@@ -613,9 +687,11 @@ async function main() {
613
687
  const sk = path.basename(path.dirname(path.dirname(args.session)));
614
688
  const excluded = loadExcludedWorkspaces();
615
689
  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})`);
690
+ const realPath = readSessionHeader(args.session).cwd; // 权威路径(header.cwd)
691
+ const record = registryRecordForPath(realPath) ?? registryRecordFor(decoded);
692
+ const prefixCh = prefixedChannels(sk, decoded, record, realPath);
693
+ if (isWorkspaceExcluded(sk, decoded, excluded, realPath) || prefixCh.length > 0) {
694
+ log("ERROR", "filter", `--session 属于排除工作区,拒绝解析: ${sk} (${realPath || decoded})`);
619
695
  process.exit(1);
620
696
  }
621
697
  files = [{ file: args.session, dir: path.basename(path.dirname(args.session)) }];
@@ -625,20 +701,31 @@ async function main() {
625
701
  const pd = path.join(SESSIONS_ROOT, projectDir);
626
702
  if (!fs.statSync(pd).isDirectory()) continue;
627
703
  if (projectDir === "Archived") continue;
704
+ // 先定位该工作区下所有可解析的会话文件(0.2.x 是 session.v4.jsonl.zstd)
705
+ const projectFiles = [];
706
+ for (const sessionDir of fs.readdirSync(pd)) {
707
+ const f = resolveSessionFile(path.join(pd, sessionDir));
708
+ if (f) projectFiles.push({ file: f, dir: sessionDir });
709
+ }
628
710
  const decoded = decodeProjectKey(projectDir);
629
- const prefixCh = prefixedChannels(projectDir, decoded, registryRecordFor(decoded));
630
- if (isWorkspaceExcluded(projectDir, decoded, excluded) || prefixCh.length > 0) {
711
+ // 权威路径:优先会话 header.cwd(绝对路径,不受 projectKey 有损编码影响)
712
+ const realPath = firstHeaderCwd(projectFiles);
713
+ const record = registryRecordForPath(realPath) ?? registryRecordFor(decoded);
714
+ const prefixCh = prefixedChannels(projectDir, decoded, record, realPath);
715
+ const listHit = isWorkspaceExcluded(projectDir, decoded, excluded, realPath);
716
+ if (listHit || prefixCh.length > 0) {
631
717
  filtered.workspace++;
632
718
  const reasons = [];
633
719
  if (prefixCh.length > 0) reasons.push(prefixCh.join(";"));
634
- if (isWorkspaceExcluded(projectDir, decoded, excluded)) reasons.push("排除名单");
635
- log("INFO", "filter", `工作区排除: ${projectDir} (${decoded}) [${reasons.join(";")}]`);
720
+ if (listHit) reasons.push("排除名单");
721
+ log("INFO", "filter", `工作区排除: ${projectDir} (${realPath || decoded}) [${reasons.join(";")}]`);
636
722
  continue;
637
723
  }
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 });
724
+ if (projectFiles.length === 0) {
725
+ log("INFO", "filter", `无转录文件跳过: ${projectDir} (${realPath || decoded})`);
726
+ continue;
641
727
  }
728
+ files.push(...projectFiles);
642
729
  }
643
730
  }
644
731
  if (files.length === 0) {