@nightpainters/dsh-ling 1.5.2 → 1.6.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,85 @@
1
+ // dsh-ling host — 模式服务(P3-A,2026-09-29):把「给某个会话设模式」做成跨插件能力。
2
+ //
3
+ // 为什么需要它:器灵的模式记录是**私有**的(memory.db 的 `session_meta.mode`),
4
+ // 而 memory / gate / settings 三个依赖只活在器灵自己的 apply 闭包里 —— 别的插件
5
+ // (如飞书桥 dsh-feishu-host)拿不到,于是「来自飞书的会话默认走生活挡位」一直做不到。
6
+ // 本模块把这个动作注册成 cordis 服务 `dsh-ling/mode`,调用方按**软依赖**取用:
7
+ //
8
+ // const ling = ctx.get('dsh-ling/mode'); // 拿不到就是 undefined,不是错误
9
+ // await ling?.applyMode(sessionId, 'life', { keepModel: { provider, model } });
10
+ //
11
+ // 契约(语义与 mode.js 一致,差别只在默认值):
12
+ // applyMode(sessionId, modeKey, opts) -> Promise<Result>
13
+ // · **不改全局** `settings.mode.lastMode`
14
+ // —— GUI 的 `applyModeToSession` 才改,那是「同时设为新会话默认」的语义;
15
+ // 跨插件调用若照做,会把飞书的挡位泄漏成桌面端的默认值。
16
+ // · opts.keepModel 调用方自带的模型。**跨插件调用必须传** —— 不传就退回
17
+ // 「读全局当前选择」,会把 GUI 正选着的模型覆盖到对方会话上。
18
+ // · opts.syncEffort 默认 true(模式的实质就是推理等级);false = 只记模式。
19
+ // · **失败不抛**:以 `{ok:false, reason}` 返回。调用方不该因为器灵缺席/出错
20
+ // 而中断自己的流程。
21
+ //
22
+ // Result = { ok, mode, reasoningEffort, warning?, queued?, lastMode?, reason? }
23
+ import { KNOWN_MODES, applyModeToSession, currentMode } from './mode.js';
24
+
25
+ export const MODE_SERVICE_NAME = 'dsh-ling/mode';
26
+
27
+ /**
28
+ * 把模式能力注册成服务。
29
+ *
30
+ * 用 `ctx.provide()` 而不是 `class extends Service`:本插件的 apply 是函数形态,
31
+ * 且 dsh-ling 以 `file:` + Junction 加载、包内无 node_modules,`import '@deepseek-ai/cordis'`
32
+ * 会走 realpath 回到**本包根目录**再向上找 —— 找不到。`ctx.provide` 是同一套
33
+ * reflect 机制的上层入口,零 import、同样由 fiber 生命周期托管(返回 disposer)。
34
+ *
35
+ * @param {object} ctx cordis 上下文(插件自己的)。
36
+ * @param {object} deps `{ gate, memory, settings }`,与 mode.js 同源。
37
+ * @returns {() => void} disposer(拆服务)。
38
+ */
39
+ export function registerModeService(ctx, { gate, memory, settings }) {
40
+ const api = {
41
+ name: MODE_SERVICE_NAME,
42
+ knownModes: KNOWN_MODES,
43
+
44
+ /**
45
+ * 给某个会话设模式。
46
+ *
47
+ * @param {string} sessionId 目标会话(飞书侧形如 `feishu-<uuid>`)。
48
+ * @param {'work'|'life'} modeKey
49
+ * @param {{keepModel?: {provider: string, model: string}, syncEffort?: boolean}} [opts]
50
+ */
51
+ async applyMode(sessionId, modeKey, opts = {}) {
52
+ if (!sessionId) return { ok: false, reason: 'no-session-id' };
53
+ if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
54
+ try {
55
+ return await applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey, {
56
+ setDefault: false, // 跨插件:只改这条会话,不动全局默认
57
+ keepModel: opts.keepModel ?? null,
58
+ syncEffort: opts.syncEffort !== false,
59
+ });
60
+ } catch (e) {
61
+ return { ok: false, reason: 'throw:' + String(e?.message ?? e) };
62
+ }
63
+ },
64
+
65
+ /** 读某个会话当前生效的模式(session_meta → lastMode → 'life')。 */
66
+ currentMode(sessionId) {
67
+ try {
68
+ return currentMode(memory, settings, sessionId);
69
+ } catch {
70
+ return 'life';
71
+ }
72
+ },
73
+ };
74
+
75
+ try {
76
+ const dispose = ctx.provide(MODE_SERVICE_NAME, api);
77
+ console.info('[dsh-ling] mode service provided: %s', MODE_SERVICE_NAME);
78
+ return typeof dispose === 'function' ? dispose : () => {};
79
+ } catch (e) {
80
+ // 同名服务已被别的 fiber 占用(例如器灵被加载了两遍):只记日志,不抛 ——
81
+ // 器灵其余功能照常,不能因为一个可选能力注册失败就整插件起不来。
82
+ console.debug('[dsh-ling] mode service provide failed', e);
83
+ return () => {};
84
+ }
85
+ }
package/lib/host/mode.js CHANGED
@@ -45,13 +45,26 @@ export function currentModelSelection(ctx) {
45
45
  return null;
46
46
  }
47
47
 
48
- async function doApply(ctx, memory, settings, sessionId, modeKey) {
48
+ /**
49
+ * @param {object} [opts] P3-A(2026-09-29) 加的调用方选项,缺省 = 原行为(GUI 路径)。
50
+ * @param {boolean} [opts.setDefault] 是否把全局 `mode.lastMode` 一起改掉(=「设为新会话默认」)。
51
+ * GUI 的 applyModeToSession 走 true;跨插件(器灵模式服务)走 false —— 否则飞书的挡位
52
+ * 会泄漏成桌面端的默认值。
53
+ * @param {?{provider:string,model:string}} [opts.keepModel] 调用方自带的「当前模型」。
54
+ * 不传才退回读**全局**当前选择;跨插件调用必须传,否则会把别人的模型写回本会话。
55
+ * @param {boolean} [opts.syncEffort] 是否同步推理等级,默认 true(模式的实质就是它)。
56
+ */
57
+ async function doApply(ctx, memory, settings, sessionId, modeKey, opts = {}) {
49
58
  const reasoningEffort = resolveModeEffort(settings.get(), modeKey);
50
59
  const controller = svc(ctx, 'sessionController');
51
60
  let warning;
52
- if (controller && typeof controller.selectModel === 'function') {
53
- const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
54
- if (!keep) {
61
+ if (opts.syncEffort === false) {
62
+ warning = 'effort-sync-skipped';
63
+ } else if (controller && typeof controller.selectModel === 'function') {
64
+ const keep = (opts.keepModel && opts.keepModel.provider && opts.keepModel.model)
65
+ ? opts.keepModel
66
+ : currentModelSelection(ctx); // 当前模型:原样带回,不改
67
+ if (!keep || !keep.provider || !keep.model) {
55
68
  warning = 'unknown-current-model; 推理等级未同步(拒绝替用户指定模型)';
56
69
  console.debug('[dsh-ling]', warning);
57
70
  } else {
@@ -73,23 +86,30 @@ async function doApply(ctx, memory, settings, sessionId, modeKey) {
73
86
  warning = 'no-sessionController; mode recorded only';
74
87
  }
75
88
  memory.setSessionMode(sessionId, modeKey);
76
- const s = await settings.update({ mode: { lastMode: modeKey } }); // D2 跟随
77
- return { ok: true, warning, sessionId, mode: modeKey, reasoningEffort, lastMode: s.mode.lastMode };
89
+ let lastMode = settings.get().mode?.lastMode;
90
+ if (opts.setDefault === true) {
91
+ const s = await settings.update({ mode: { lastMode: modeKey } }); // D2 跟随
92
+ lastMode = s.mode.lastMode;
93
+ }
94
+ return { ok: true, warning, sessionId, mode: modeKey, reasoningEffort, lastMode };
78
95
  }
79
96
 
80
97
  /**
81
98
  * Apply mode to a session (D4): running → queue (returns queued), idle → apply now.
99
+ *
100
+ * @param {object} [opts] 见 doApply。缺省 `{setDefault:true}` = GUI 语义(改本会话 + 设为默认)。
82
101
  */
83
- export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey) {
102
+ export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey, opts = {}) {
84
103
  if (!sessionId) return { ok: false, reason: 'no-session-id' };
85
104
  if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
105
+ const merged = { setDefault: true, ...opts };
86
106
  const queued = gate.enqueueIfRunning(sessionId, () => {
87
- doApply(ctx, memory, settings, sessionId, modeKey).catch((e) =>
107
+ doApply(ctx, memory, settings, sessionId, modeKey, merged).catch((e) =>
88
108
  console.debug('[dsh-ling] queued mode apply failed', e),
89
109
  );
90
110
  });
91
111
  if (queued) return { ok: true, queued: true, running: true, sessionId, mode: modeKey };
92
- return await doApply(ctx, memory, settings, sessionId, modeKey);
112
+ return await doApply(ctx, memory, settings, sessionId, modeKey, merged);
93
113
  }
94
114
 
95
115
  /** Current mode for a session: session_meta → settings.lastMode. */
@@ -63,8 +63,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
63
63
  },
64
64
  memory: {
65
65
  l1Enabled: true,
66
- l1BudgetTokens: 1200,
67
- l1MaxItems: 8,
66
+ l1BudgetTokens: 720, // 1.6 步 5:1200 → 720(「瘦身到现在的 60%」,尝生 2026-10-04 拍)
67
+ l1MaxItems: 10, // 1.6:8 → 10(尝生 2026-10-04:「8 条也可以考虑改 10 条,其中 2 条作为最硬的事实的席位」)
68
68
  l1MaxLineChars: 180, // 兜底硬顶:只对病态长标题生效(截断优先落在句末)
69
69
  l1SummaryChars: 110, // 摘要段预算:第一句超此长度才硬截并标 …(2026-09-16 与用户定案)
70
70
  categoryWeights: { // mode -> 领域类别权重(D6,先验倾向)
@@ -0,0 +1,317 @@
1
+ // dsh-ling host — 直通原文(1.6 · P2 第一优先)。
2
+ //
3
+ // 为什么要有这个模块(2026-10-04 尝生定「第一优先级就是先把读原文的这个事儿给办了」):
4
+ // 器灵此前**没有任何正规通道**能读会话原文 —— 7 个工具里一个都读不到,
5
+ // 而 memory.db 的 `dsh_turns_raw` 是**残的**(本会话实测:库里 13 条 vs 磁盘 473 条,≈2.7%)。
6
+ // 真全文只躺在磁盘上: ~/.dsh/sessions/<项目目录>/<会话目录>/session.v4.jsonl.zstd
7
+ //
8
+ // 磁盘格式(实测): **多 frame zstd 容器**,一帧一批事件,帧内是 JSONL。
9
+ // ⚠️ 三个坑,每个都踩过:
10
+ // ① `zstdDecompressSync(整文件)` 只解**第一帧**就返回(实测本会话只出 191 字节的会话头),
11
+ // 看起来"解压成功",极易被当成"文件就是这么小";
12
+ // ② 流式 `createZstdDecompress()` 直接报 `Unknown frame descriptor`;
13
+ // ③ 靠 magic 逐字节暴力试解边界是 **O(n²)** —— 实测 498 帧耗时 **119 秒**,不可用。
14
+ // ⇒ 正解是**纯结构扫描**帧边界(不解压),再逐帧解压。本模块照 DSH 官方实现重写
15
+ // (@deepseek-ai/dsh-session-persistence-jsonl 的 `scanZstdFrames`,见其 lib/index.js:1300-1363),
16
+ // 实测 **516 帧扫描 1ms + 全量解压解析 53ms**(相对暴力法快约 2200 倍)。
17
+ // 抄而不 import 的理由:不把插件绑到宿主内部包上(那是 P3 的劣,主人 2026-10-04 拍「先 P2」)。
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { zstdDecompressSync, constants as zc } from 'node:zlib';
22
+ import { isMemoryEligibleHeader } from './util.js'; // F3(2026-10-04):真人判据要按**会话级**分通道
23
+
24
+ /** Zstandard 帧魔数(小端读作 UInt32LE 时的值)。 */
25
+ const ZSTD_MAGIC = 0xfd2fb528;
26
+ /** 未完成末帧的兜底解压选项(与官方同款:`ZSTD_e_flush` 抑制末帧/校验和完成判定)。 */
27
+ const INCOMPLETE_FRAME_OPTIONS = { finishFlush: zc.ZSTD_e_flush };
28
+ /** 会话日志的候选文件名(**新→旧**)。DSH 换过三代: v4 / v3 / 无版本。
29
+ * ⚠️ 2026-10-04 实测:此处原先写死 v4 ⇒ 磁盘上 **117 个 `session.v3.jsonl.zstd` + 53 个
30
+ * `session.jsonl.zstd`** 全部读不到(仅**一个**会话目录下就有 82 个)。
31
+ * 而三代**事件同构** —— 都以 `{"type":"session","version":N}` 开头、事件类型一致、
32
+ * `dialogTurns` 直接可用(v3 与无版本各实测两份,零格式适配)。
33
+ * ⇒ 判据只能是「**文件在不在**」,不是「叫什么名字」。 */
34
+ const SESSION_LOG_NAMES = ['session.v4.jsonl.zstd', 'session.v3.jsonl.zstd', 'session.jsonl.zstd'];
35
+
36
+ /** 在一个会话目录里按 新→旧 找到日志文件;找不到返回 null。
37
+ * @returns {{file:string,name:string,bytes:number,mtime:number}|null} */
38
+ function findLogIn(dir) {
39
+ for (const name of SESSION_LOG_NAMES) {
40
+ const file = path.join(dir, name);
41
+ try {
42
+ const st = fs.statSync(file);
43
+ if (st.isFile()) return { file, name, bytes: st.size, mtime: Math.round(st.mtimeMs) };
44
+ } catch { /* 试下一个名字 */ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** 会话根目录: ~/.dsh/sessions。 */
50
+ export function sessionsRoot() {
51
+ return path.join(os.homedir(), '.dsh', 'sessions');
52
+ }
53
+
54
+ /**
55
+ * 纯结构扫描 zstd 帧边界 —— **不解压任何 block**。
56
+ * 逐帧: magic(4) → frame header descriptor(1) → 剩余头 → block 循环(每块 3 字节头 + 载荷)。
57
+ * @param {Buffer} buffer 会话日志的全部字节
58
+ * @param {number} [maxFrames] 只要前 N 帧(元数据读取用;省掉后面的扫描)
59
+ * @returns {{frames:{start:number,end:number}[], tornStart?:number}} 完整帧区间;末尾被截断时给起始偏移
60
+ */
61
+ export function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
62
+ const frames = [];
63
+ let offset = 0;
64
+ while (offset < buffer.length) {
65
+ const start = offset;
66
+ if (buffer.length - offset < 4) return { frames, tornStart: start };
67
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
68
+ throw new Error(`会话日志损坏:字节 ${offset} 处不是 zstd 帧头`);
69
+ }
70
+ offset += 4;
71
+ if (offset === buffer.length) return { frames, tornStart: start };
72
+ const descriptor = buffer.readUInt8(offset);
73
+ offset += 1;
74
+ if ((descriptor & 24) !== 0) throw new Error(`会话日志损坏:字节 ${offset - 1} 处保留位非零`);
75
+ const contentSizeFlag = descriptor >>> 6;
76
+ const singleSegment = (descriptor & 32) !== 0;
77
+ const checksum = (descriptor & 4) !== 0;
78
+ const dictionaryFlag = descriptor & 3;
79
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
80
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
81
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
82
+ if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start };
83
+ offset += remainingHeaderBytes;
84
+ for (;;) {
85
+ if (buffer.length - offset < 3) return { frames, tornStart: start };
86
+ const blockHeader = buffer.readUIntLE(offset, 3);
87
+ offset += 3;
88
+ const lastBlock = (blockHeader & 1) !== 0;
89
+ const blockType = (blockHeader >>> 1) & 3;
90
+ const blockSize = blockHeader >>> 3;
91
+ if (blockType === 3) throw new Error(`会话日志损坏:字节 ${offset - 3} 处保留块类型`);
92
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
93
+ if (buffer.length - offset < payloadBytes) return { frames, tornStart: start };
94
+ offset += payloadBytes;
95
+ if (lastBlock) break;
96
+ }
97
+ if (checksum) {
98
+ if (buffer.length - offset < 4) return { frames, tornStart: start };
99
+ offset += 4;
100
+ }
101
+ frames.push({ start, end: offset });
102
+ if (frames.length === maxFrames) return { frames };
103
+ }
104
+ return { frames };
105
+ }
106
+
107
+ /**
108
+ * 列出磁盘上的会话目录(权威的"有原文可读"清单) —— 只读目录名与文件大小,不解压。
109
+ * 为什么不从库里列: `conv_overview` 只有 1,600+ 概述、`dsh_turns_raw` 只有 116 个会话,
110
+ * 而磁盘有 500+ 个会话目录 ⇒ **库比磁盘少**,要读原文就得认磁盘。
111
+ * @returns {{ok:boolean, sessions:{conv:string, project:string, bytes:number, mtime:number}[], projects:number}}
112
+ */
113
+ export function listSessionFiles({ limit = 40, project = '' } = {}) {
114
+ const root = sessionsRoot();
115
+ let projs;
116
+ try {
117
+ projs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
118
+ } catch (e) {
119
+ return { ok: false, reason: 'no-sessions-root', message: String(e?.message ?? e), sessions: [], projects: 0 };
120
+ }
121
+ const sessions = [];
122
+ for (const p of projs) {
123
+ if (project && p !== project) continue;
124
+ let dirs;
125
+ try {
126
+ dirs = fs.readdirSync(path.join(root, p), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
127
+ } catch { continue; }
128
+ for (const c of dirs) {
129
+ const hit = findLogIn(path.join(root, p, c)); // 没有会话日志的目录(如纯 taskfold)不算
130
+ if (!hit) continue;
131
+ sessions.push({ conv: c, project: p, bytes: hit.bytes, mtime: hit.mtime, log: hit.name });
132
+ }
133
+ }
134
+ sessions.sort((a, b) => b.mtime - a.mtime); // 最近动过的在前 —— 想读的多半是刚发生的
135
+ return { ok: true, sessions: sessions.slice(0, Math.max(1, limit)), total: sessions.length, projects: projs.length };
136
+ }
137
+
138
+ /**
139
+ * 定位一个会话的日志文件。conv 接受三种写法: `session-<uuid>` / `<uuid>` / 目录名原样。
140
+ * @returns {{file:string, dir:string, project:string, id:string}|null}
141
+ */
142
+ export function resolveSessionFile(conv) {
143
+ const want = String(conv || '').trim();
144
+ if (!want) return null;
145
+ const bare = want.replace(/^session-/, '');
146
+ const names = Array.from(new Set([want, bare, `session-${bare}`]));
147
+ const root = sessionsRoot();
148
+ let projs;
149
+ try {
150
+ projs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
151
+ } catch { return null; }
152
+ for (const p of projs) {
153
+ for (const n of names) {
154
+ const dir = path.join(root, p, n);
155
+ const hit = findLogIn(dir);
156
+ if (hit) return { file: hit.file, dir, project: p, id: n, log: hit.name };
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * 读一个会话的全部事件。**这是唯一"解压"的入口** —— 单会话实测 ~53ms。
164
+ * @returns {{ok:boolean, reason?:string, events?:object[], frames?:number, tornStart?:number, bytes?:number}}
165
+ */
166
+ export function readSessionEvents(conv) {
167
+ const hit = resolveSessionFile(conv);
168
+ if (!hit) return { ok: false, reason: 'no-session', conv: String(conv || '') };
169
+ let buf;
170
+ try { buf = fs.readFileSync(hit.file); } catch (e) {
171
+ return { ok: false, reason: 'io', conv: hit.id, message: String(e?.message ?? e) };
172
+ }
173
+ let scanned;
174
+ try { scanned = scanZstdFrames(buf); } catch (e) {
175
+ return { ok: false, reason: 'corrupt', conv: hit.id, message: String(e?.message ?? e) };
176
+ }
177
+ const events = [];
178
+ for (const { start, end } of scanned.frames) {
179
+ let txt;
180
+ try { txt = zstdDecompressSync(buf.subarray(start, end)).toString('utf8'); } catch { continue; }
181
+ for (const line of txt.split('\n')) {
182
+ const s = line.trim();
183
+ if (!s) continue;
184
+ try { events.push(JSON.parse(s)); } catch { /* 半行/非 JSON,跳过 */ }
185
+ }
186
+ }
187
+ let tornPlain = 0;
188
+ if (scanned.tornStart !== undefined) {
189
+ // 末帧被截断(会话正写到一半):官方用 ZSTD_e_flush 兜底 —— 捞出能捞的部分,别整份判失败。
190
+ try {
191
+ tornPlain = zstdDecompressSync(buf.subarray(scanned.tornStart), INCOMPLETE_FRAME_OPTIONS)
192
+ .toString('utf8').split('\n').filter((l) => l.trim()).length;
193
+ } catch { /* 捞不出来就算了 */ }
194
+ }
195
+ return {
196
+ ok: true, ...hit, events,
197
+ frames: scanned.frames.length,
198
+ tornStart: scanned.tornStart,
199
+ tornLines: tornPlain,
200
+ bytes: buf.length,
201
+ };
202
+ }
203
+
204
+ /** 从 content 数组里取文本(可限制只取哪几类块;默认只取正文,不取思维链)。 */
205
+ function textOfContent(content, allow = ['text']) {
206
+ if (typeof content === 'string') return content;
207
+ if (!Array.isArray(content)) return '';
208
+ const parts = [];
209
+ for (const b of content) {
210
+ if (!b || typeof b !== 'object') continue;
211
+ if (!allow.includes(b.type)) continue;
212
+ if (typeof b.text === 'string' && b.text) parts.push(b.text);
213
+ }
214
+ return parts.join('\n');
215
+ }
216
+
217
+ /**
218
+ * 把事件流收成**对话轮** —— 这就是"深层库要什么"的第一层过滤:
219
+ * 只留 `user/message` 与 `assistant/message`,丢掉 tool/call、tool/result、step、compaction 等全部过程事件。
220
+ * ⚠️ user 消息里**真人只占少数**(注入的 runtime-context / skill-catalog / task-marks 也是 user/message)。
221
+ * 判据是 `data.source.kind === 'user'`(真人)**且**带 rpcId;其余一律标 `human:false`。
222
+ * @returns {{turns:object[], injected:number, assistantEmpty:number}}
223
+ */
224
+ export function dialogTurns(events) {
225
+ const evs = events || [];
226
+ // ⚠️ F3(蓝队 2026-10-04 全盘实测 + 修法设计):事件里**没有通道字段** —— 飞书真人、身份注入块、
227
+ // 子代理委托种子在消息级**完全同形**(都经 `agent/inbox/spliced`)。"按通道分"只能落在**会话级**:
228
+ // 从 `session` 事件取 header(**字段在事件顶层,不在 `data` 里**)过 `isMemoryEligibleHeader`,
229
+ // 与捕获侧(`lifecycle.js:217/355`)同一把尺。
230
+ // 判据五条:① `kind==='user'` ② 会话合格(top|fork) ③ 合格会话里**无 rpcId** 的 user 认真人
231
+ // (飞书桥来的主人真话没有 rpcId) ④ 正文行首 `/^\[身份[·・]/` 仍算注入(persona.js 自有段头)
232
+ // ⑤ 取不到 session 头 ⇒ 只认 rpcId(与修前逐字同解,保住无头夹具与旧日志)。
233
+ // 实测(蓝队全盘 501 会话):真人轮 951 → **1001**(+49 飞书真人 +1 探针),注入 7049 → 6999,
234
+ // 子代理种子 387 条 **true→false 回归 0**。
235
+ let sessionEligible = null;
236
+ for (const e of evs) {
237
+ if (e && typeof e === 'object' && e.type === 'session' && e.header) {
238
+ sessionEligible = isMemoryEligibleHeader(e.header);
239
+ break;
240
+ }
241
+ }
242
+ const turns = [];
243
+ let injected = 0;
244
+ let assistantEmpty = 0;
245
+ for (const e of evs) {
246
+ if (!e || typeof e !== 'object') continue;
247
+ if (e.type === 'user/message') {
248
+ const d = e.data || {};
249
+ const src = d?.source || {};
250
+ const text = textOfContent(d.content);
251
+ const identityBlock = /^\[身份[·・]/.test(String(text || '').trim());
252
+ let human;
253
+ if (identityBlock) human = false; // ④ 身份块永远是注入
254
+ else if (src.kind !== 'user') human = false; // ① kind 必须是 user
255
+ else if (src.rpcId) human = sessionEligible !== false; // ②⑤ 有 rpcId:沿用旧判据(子代理会话除外)
256
+ else human = sessionEligible === true; // ③ 无 rpcId:只在合格会话里认真人
257
+ if (!human) injected += 1;
258
+ turns.push({
259
+ seq: e.seq, time: e.time, role: 'user', human, text,
260
+ });
261
+ } else if (e.type === 'assistant/message') {
262
+ const d = e.data || {};
263
+ const msg = d.message || {};
264
+ const text = textOfContent(msg.content, ['text']);
265
+ // ⚠️ 实测(本会话 114 条 assistant/message):`text` 块只有 10 条,**绝大多数轮次是"只调工具、没有正文"**
266
+ // (块类型是 `reasoning` / `tool-call` / `text` —— 注意是 `tool-call` 不是 `tool_use`)。
267
+ // ⇒ 无正文时把**调了哪些工具**带出来,否则目录里会是一串空白行,读不出发生过什么。
268
+ // 同名工具**合并计数**(实测:一轮里并行调两个 pwsh,原样列出就是 `pwsh, pwsh` 这种噪音)。
269
+ const tools = [];
270
+ if (Array.isArray(msg.content)) {
271
+ const counts = new Map();
272
+ for (const b of msg.content) {
273
+ if (b?.type !== 'tool-call' || !b.name) continue;
274
+ const k = String(b.name);
275
+ counts.set(k, (counts.get(k) || 0) + 1);
276
+ }
277
+ for (const [k, c] of counts) tools.push(c > 1 ? `${k}×${c}` : k);
278
+ }
279
+ if (!text) assistantEmpty += 1;
280
+ turns.push({ seq: e.seq, time: e.time, role: 'assistant', human: false, text, tools, turn: d.turn, step: d.step });
281
+ }
282
+ }
283
+ turns.forEach((t, i) => { t.n = i + 1; }); // 人读序号(1-based);seq 是事件流里的原始序号
284
+ return { turns, injected, assistantEmpty };
285
+ }
286
+
287
+ /** 把 `dsh_turns_raw` 的行(seq/role/ts/text)还原成**事件形状**,好让 `dialogTurns` 直接吃。
288
+ * 1.6(2026-10-04):`recall` 原本只读磁盘 ⇒ 库里的原文(实测 15 个 `import:<id>` 会话)够不着,
289
+ * 而磁盘那 465 个会话里没有它们。这是"读侧改道"的**反面补充** —— 库里那份也要能读,
290
+ * 只是它没有事件流、只有轮次。
291
+ * ⚠️ 库里的 user 行**都已是真人**(写入时已按 `source.kind === 'user'` 过滤过) ⇒ 直接按真人标。 */
292
+ export function eventsFromRows(rows) {
293
+ return (rows || []).map((r) => {
294
+ const ts = Date.parse(r?.ts);
295
+ const time = Number.isFinite(ts) ? ts : 0;
296
+ const text = String(r?.text ?? '');
297
+ return r?.role === 'user'
298
+ ? { type: 'user/message', seq: r?.seq, time, data: { content: [{ type: 'text', text }], source: { kind: 'user', rpcId: 'db' } } }
299
+ : { type: 'assistant/message', seq: r?.seq, time, data: { message: { content: [{ type: 'text', text }] } } };
300
+ });
301
+ }
302
+
303
+ /** 毫秒时间戳 → 本地 `MM-DD HH:mm`(与 memory.js 的口径一致:给人看的走本地钟)。 */
304
+ export function localStamp(ms) {
305
+ const n = Number(ms);
306
+ if (!Number.isFinite(n) || n <= 0) return '';
307
+ const d = new Date(n);
308
+ const p = (x) => String(x).padStart(2, '0');
309
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
310
+ }
311
+
312
+ /** 单行化 + 截断(目录视图用)。名字不叫 flatOne:`tools.js` 里已有一个同名的(签名不同)。 */
313
+ export function oneLine(s, cap = 60) {
314
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
315
+ if (!t) return '';
316
+ return t.length > cap ? t.slice(0, cap) + '…' : t;
317
+ }