dsh-anima 0.0.0-stage → 2.2.2

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,296 @@
1
+ // lib/retrospect.mjs — 回扫式复盘:从旧会话里捞出「当时该记却没记」的线索
2
+ //
3
+ // 为什么需要它(这是实测出来的,不是想出来的):
4
+ // anima 的记忆层长期只有 2 条,`pending` 恒为 0。翻 `C:\Users\zmy\.dsh\sessions`
5
+ // 下 53 个会话、剔除噪音后得到 925 条真实用户发言,用 mnemon 的「两级漏斗」实测:
6
+ // 第一段四条判据只命中 4 条(0.4%),第二段打分公式却让 44.6% 的轮次送审 ——
7
+ // 要么太严要么太宽,中间没有可用档位(记在 docs/peers/mnemon.md:287-308 与
8
+ // 本仓的探针结果里)。常驻闸门这条路在真实语料上站不住。
9
+ //
10
+ // 真正的证据是两条时间戳:
11
+ // 2026-11-04T10:13:24.360Z「我有没有说过不允许一直往c盘写东西…」
12
+ // 2026-11-04T10:14:59.214Z「非必要不往c盘写是不是我是不是说过…」
13
+ // 而这条规则被写进记忆是 2026-11-05T04:59:24.335Z,**晚了一天**。
14
+ // 写进去之后,语料里再没有同类抱怨 —— 说明写下来的记忆确实被用上了。
15
+ // 所以问题在「当时没写下来」,不在「写了没用上」。
16
+ //
17
+ // 由此定的方向:**不碰实时对话,只回扫历史**。实时对话里插闸门要付「每轮都判」
18
+ // 的代价,而真实信号出现率是 937 轮里 2 次;回扫是批量、可重跑、可 dry-run 的。
19
+ //
20
+ // 本模块只做「捞线索」,**不生成记忆、不写库、不调模型**。三条硬规矩:
21
+ // ① 捞出来的原文是用户自己那句话,不是我改写的记忆条目 —— 改写就等于我替用户下结论。
22
+ // ② 每条线索必须带出处(会话文件 + 事件序号 + 时间),给不出就不收。
23
+ // ③ 没有线索就说没有;不许为了「有产出」而放宽判据。
24
+
25
+ /** 线索类型。词典顺序即优先级:一个问题只算一次,先命中的算。 */
26
+ export const LEAD_KINDS = ['complaint', 'explicit', 'correction', 'constraint']
27
+
28
+ /**
29
+ * 判据表。每一条都是在这台机器的真实语料上量过的,阈值后面注明命中率
30
+ * (分母 = 925 条真实用户发言,2026-11-08 实测)。
31
+ *
32
+ * 刻意**没有**收录的两种:
33
+ * · 单独的「不要」—— 42 条命中(4.5%),但大量是「不要重试」「不用了」这类
34
+ * 一次性指令,不是长期约束。宽到这个程度,线索表会变成聊天记录复制品。
35
+ * · 情绪词(生气 / 无语)—— 情绪不等于信息,抱怨里真正有用的是「我说过」这层。
36
+ */
37
+ const LEAD_PATTERNS = [
38
+ {
39
+ kind: 'complaint',
40
+ // 「我说过」类:用户指出某条规则早就交代过 —— 这是最强的「该记没记」信号。
41
+ // 实测 925 条语料里只有 2 条(0.2%)。宁少不滥。
42
+ re: /(我说过|有没有说过|不是说过|我跟你说过|早说过|说过多少次|又说一遍|还要我说)/,
43
+ why: '用户指出这条早已交代过'
44
+ },
45
+ {
46
+ kind: 'explicit',
47
+ // 用户直接要求记住。实测 1 条(0.1%)。
48
+ // 英文只认祈使式 `remember this/that` / `save memory` —— 裸 `remember` 会命中
49
+ // 「all AI remember the same you」这类产品介绍,实测踩过一次。
50
+ re: /(记住|记下来|记一下|记着|别忘了|从今往后|长期记住)|(remember (this|that)|save (this to )?memory)/i,
51
+ why: '用户明确要求记住'
52
+ },
53
+ {
54
+ kind: 'correction',
55
+ // 用户指出我搞错了。实测 6 条(0.6%)。
56
+ // 刻意**不收**裸「错了」:「点错了」「改错了怎么办」是选错选项/提问,不是纠正。
57
+ // 收粗口不是在做道德判断 —— 实测里纠正往往伴随粗口,它是强信号不是脏话。
58
+ // 负向先行:出现「点错 / 改错 / 记错 / 按错」就整句不判纠正。
59
+ re: /(不对|搞错|搞反|理解错|你说错|谁让你|你他妈|你tm|你踏马|我不是这个意思|我说的是)/,
60
+ notBefore: /(点|改|按|记|写|选|填|输)错/,
61
+ why: '用户纠正了一次错误'
62
+ },
63
+ {
64
+ kind: 'constraint',
65
+ // 带否定词**且**带持久性限定的长期约束。
66
+ // 刻意不收「不能」:语料里它几乎全是能力陈述(「不能一直运行」「不能打开」),
67
+ // 不是规则。实测这一条改动把 constraint 的假阳性从 8 条降到 3 条。
68
+ // 刻意不收裸「再」:「不要再崩溃了」是当场的请求,不是长期规则。
69
+ // 用 [^。!?\n]{0,24} 而不是 \S{0,20}:后者会跨中文标点连通两句话。
70
+ re: /(不许|禁止|不准|严禁|不要|别)[^。!?\n]{0,24}(所有|任何|每次|一律|一直|总是|以后)/,
71
+ why: '用户下了一条禁止性规则'
72
+ }
73
+ ]
74
+
75
+ /** 单条线索原文的字符上限。超了就截断并标注,不整条丢。 */
76
+ export const MAX_LEAD_CHARS = 240
77
+ /** 一次回扫最多产出多少条线索。 */
78
+ export const MAX_LEADS = 30
79
+ /** 单条发言拆句时的上限:再长的句子多半是粘贴的日志,不是一句话。 */
80
+ const MAX_SENTENCE_CHARS = 400
81
+
82
+ /**
83
+ * 判断一条 user/message 是不是「真用户说的」。
84
+ *
85
+ * 这一步是必须的,不是洁癖:实测 53 个会话里 user 角色的消息有一大半是伪装 ——
86
+ * system-reminder 快照、subagent 任务书、任务板卡片、团队消息、另一个插件
87
+ * (论衡)的产物。不过滤的话,线索表里会混进「你的任务:…」「不要调用任何工具」
88
+ * 这种我写给自己的话。实测:过滤前 18 条线索,过滤后 11 条,去掉的 7 条全是假的。
89
+ *
90
+ * @param {string} text
91
+ * @param {string} [sourceKind] 事件里的 `source.kind`;以 `plugin:` 开头的是插件注入
92
+ * @param {string} [rpcId] 事件里的 `source.rpcId`;`task-board-` 开头的是任务板卡片
93
+ * @returns {boolean}
94
+ */
95
+ export function isRealUserText(text, sourceKind = '', rpcId = '') {
96
+ const t = String(text ?? '').trim()
97
+ if (t.length === 0) return false
98
+ if (String(sourceKind ?? '').startsWith('plugin:')) return false
99
+ if (String(rpcId ?? '').startsWith('task-board-')) return false
100
+ const NOISE = [
101
+ /^<system-reminder>/,
102
+ /^Background subagent /,
103
+ /^Team message from /,
104
+ /^Agent \S+ sent a message/,
105
+ /^你的任务:/,
106
+ /^任务:/,
107
+ /^你在 [A-Z]:\\/,
108
+ /^请实现/,
109
+ /^〔论衡/,
110
+ /^\[dsh-anima /,
111
+ /^Provider finish_reason/,
112
+ /^Time sampled while preparing turn/,
113
+ // `只回一行:OK。不要调用任何工具,不要读文件。` —— 探针/子任务下发的指令。
114
+ // 它的 rpcId 是普通 uuid(不是 task-board- 前缀),所以只能靠首句特征认。
115
+ // 人不会用「只回一行:OK」这种句子跟别人说话。
116
+ /^只回一行/
117
+ ]
118
+ return !NOISE.some((re) => re.test(t))
119
+ }
120
+
121
+ /**
122
+ * 把一段发言拆成句子。中文句读 + 换行。
123
+ *
124
+ * 为什么要拆:一整条 300 字的抱怨里,有用的往往只有一句。「说了很多但可被证伪的
125
+ * 断言接近零」正是要避免的形状 —— 线索要短、要能对上原话。
126
+ *
127
+ * @param {string} text
128
+ * @returns {string[]}
129
+ */
130
+ export function splitSentences(text) {
131
+ return String(text ?? '')
132
+ .split(/[。!?;\n]+/)
133
+ .map((s) => s.trim())
134
+ .filter((s) => s.length > 0 && s.length <= MAX_SENTENCE_CHARS)
135
+ }
136
+
137
+ /**
138
+ * 给一句话判类。命中不到就返回 null(**不收**,不是收进「其它」桶)。
139
+ *
140
+ * @param {string} sentence
141
+ * @returns {{kind: string, why: string}|null}
142
+ */
143
+ export function classifySentence(sentence) {
144
+ const s = String(sentence ?? '')
145
+ if (s === '') return null
146
+ for (const rule of LEAD_PATTERNS) {
147
+ if (rule.notBefore && rule.notBefore.test(s)) continue
148
+ if (rule.re.test(s)) return { kind: rule.kind, why: rule.why }
149
+ }
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * 从事件表里捞线索。
155
+ *
156
+ * 入参刻意做成「已经解好的事件数组」,不在这里读文件:这样它能被单元测试喂夹具,
157
+ * 也能被脚本喂 `readSessionEvents()` 的结果,还能被宿主喂别的来源。
158
+ *
159
+ * @param {object} input
160
+ * @param {Array<{seq?:number, at?:string, type?:string, text?:string, sourceKind?:string}>} input.events
161
+ * @param {number} [input.sinceMs] 只看这个时刻之后的事件(毫秒时间戳);不给则全看
162
+ * @param {number} [input.maxLeads]
163
+ * @param {string} [input.sessionRef] 会话标识,写进线索的出处
164
+ * @returns {{leads: Array<{kind:string, why:string, text:string, sessionRef:string, seq:number|null, at:string}>, scanned: number, matched: number, skipped: number}}
165
+ */
166
+ export function extractLeads({ events = [], sinceMs = 0, maxLeads = MAX_LEADS, sessionRef = '' } = {}) {
167
+ const leads = []
168
+ let scanned = 0
169
+ let matched = 0
170
+ let skipped = 0
171
+ const floor = Number(sinceMs) > 0 ? Number(sinceMs) : 0
172
+
173
+ for (const ev of Array.isArray(events) ? events : []) {
174
+ if (ev?.type !== 'user/message') continue
175
+ const at = typeof ev.at === 'string' ? ev.at : ''
176
+ const atMs = at ? Date.parse(at) : NaN
177
+ if (floor > 0 && Number.isFinite(atMs) && atMs < floor) continue
178
+ const text = ev.text ?? ''
179
+ if (!isRealUserText(text, ev.sourceKind, ev.rpcId)) {
180
+ skipped += 1
181
+ continue
182
+ }
183
+ scanned += 1
184
+ for (const sentence of splitSentences(text)) {
185
+ const hit = classifySentence(sentence)
186
+ if (!hit) continue
187
+ matched += 1
188
+ if (leads.length >= maxLeads) break
189
+ const clipped = sentence.length > MAX_LEAD_CHARS
190
+ ? `${sentence.slice(0, MAX_LEAD_CHARS)}…`
191
+ : sentence
192
+ leads.push({
193
+ kind: hit.kind,
194
+ why: hit.why,
195
+ text: clipped,
196
+ sessionRef: String(sessionRef ?? ''),
197
+ seq: Number.isInteger(ev.seq) ? ev.seq : null,
198
+ at
199
+ })
200
+ }
201
+ if (leads.length >= maxLeads) break
202
+ }
203
+ return { leads, scanned, matched, skipped }
204
+ }
205
+
206
+ /** 线索的去重键:去掉空白与标点后的正文。同一句话在不同会话里出现两次只报一次。 */
207
+ export function leadKey(lead) {
208
+ return String(lead?.text ?? '').replace(/[\s,。!?;:、,.!?;:"'「」『』()()]/g, '').slice(0, 120)
209
+ }
210
+
211
+ /**
212
+ * 判断这次回扫该不该出声。
213
+ *
214
+ * 与 `assessPatrol` 同一条纪律:**只看「有没有新的」**。上周捞到的线索这周还在,
215
+ * 不重复报 —— 用户早就知道了,再报一遍就是噪音。减少(用户已经处理掉)也不报,
216
+ * 那是把事情办完的结果,不该换来一条通知。
217
+ *
218
+ * @param {object} input
219
+ * @param {Array} [input.leads] 本次捞到的线索
220
+ * @param {string[]} [input.previousKeys] 上次已报过的去重键
221
+ * @returns {{shouldReport: boolean, newLeads: Array, added: number, reasons: string[]}}
222
+ */
223
+ export function assessRetrospect({ leads = [], previousKeys = [] } = {}) {
224
+ const list = Array.isArray(leads) ? leads : []
225
+ const prev = new Set((Array.isArray(previousKeys) ? previousKeys : []).map(String))
226
+ const seen = new Set()
227
+ const newLeads = []
228
+ for (const lead of list) {
229
+ const key = leadKey(lead)
230
+ if (key === '' || prev.has(key) || seen.has(key)) continue
231
+ seen.add(key)
232
+ newLeads.push({ ...lead, key })
233
+ }
234
+ const reasons = []
235
+ if (newLeads.length > 0) reasons.push(`leads:${newLeads.length}`)
236
+ return { shouldReport: newLeads.length > 0, newLeads, added: newLeads.length, reasons }
237
+ }
238
+
239
+ /**
240
+ * 把线索压成可以安全落盘的那一份。
241
+ *
242
+ * 为什么压:`store` 的 metaSchema 会把不认识的字段剥掉(2026-10-24 那个 bug 的形状),
243
+ * 原样塞 `leads` 全量对象,读回来就只剩一个空数组 —— 「写了但读不回来」。
244
+ * 只留三样:去重键(判断新旧用)、类型、正文首段。
245
+ *
246
+ * @param {Array} leads
247
+ * @param {number} [max] 最多落盘几条
248
+ * @returns {{items: Array<{key:string, kind:string, text:string}>, dropped: number}}
249
+ */
250
+ export function compactLeads(leads, max = MAX_LEADS) {
251
+ const list = Array.isArray(leads) ? leads : []
252
+ const cap = Number.isInteger(max) && max > 0 ? max : MAX_LEADS
253
+ const items = []
254
+ const seen = new Set()
255
+ for (const lead of list) {
256
+ const key = typeof lead?.key === 'string' && lead.key ? lead.key : leadKey(lead)
257
+ if (!key || seen.has(key)) continue
258
+ seen.add(key)
259
+ if (items.length >= cap) continue
260
+ items.push({
261
+ key,
262
+ kind: String(lead?.kind ?? ''),
263
+ text: String(lead?.text ?? '').slice(0, MAX_LEAD_CHARS)
264
+ })
265
+ }
266
+ return { items, dropped: Math.max(0, list.length - items.length) }
267
+ }
268
+
269
+ /**
270
+ * 渲染给人看的那段话。
271
+ *
272
+ * 空数组返回空串(**不是**空指针,也不是一句「没找到」)—— 与注入层的约定一致:
273
+ * 空串 = 沉默,由调用方决定要不要因此发消息。
274
+ *
275
+ * @param {Array} leads 本次新增的线索
276
+ * @param {object} [opts]
277
+ * @param {number} [opts.maxShown] 最多展示几条
278
+ * @returns {string}
279
+ */
280
+ export function retrospectNoticeText(leads, { maxShown = 8 } = {}) {
281
+ const list = Array.isArray(leads) ? leads.filter((l) => l && l.text) : []
282
+ if (list.length === 0) return ''
283
+ const shown = list.slice(0, Math.max(1, Number(maxShown) || 8))
284
+ const lines = [
285
+ '[dsh-anima 复盘] 回扫旧会话时,找到几处你当时说过、但没被记下来的话:'
286
+ ]
287
+ for (const lead of shown) {
288
+ const where = lead.sessionRef ? `(${lead.sessionRef})` : ''
289
+ lines.push(`· [${lead.kind}] ${lead.text}${where}`)
290
+ }
291
+ if (list.length > shown.length) {
292
+ lines.push(`· 另有 ${list.length - shown.length} 条未列出。`)
293
+ }
294
+ lines.push('这些只是线索,不是结论。要不要记、记成什么,由你决定。')
295
+ return lines.join('\n')
296
+ }
@@ -0,0 +1,174 @@
1
+ // lib/sessions.mjs — 读宿主会话日志(zstd 分帧),只读
2
+ //
3
+ // 这段逻辑原本只在 `scripts/` 下(check-hook / remind-effect / trace-sources 三处
4
+ // 各写过一遍,后来抽成 scripts/zstd-frames.mjs)。现在回扫式复盘要在**运行时**
5
+ // 用它,而 `package.json` 的 `files` 里没有 `scripts/` —— 线上装好的插件根本看不到
6
+ // 那个目录。所以真正实现在这里,`scripts/zstd-frames.mjs` 改成从这里转出,
7
+ // 保持四个开发脚本的 import 路径不变,同时只有一份实现(改一处漏两处的老问题)。
8
+ //
9
+ // 一条必须守住的规矩:**空结果和读失败不能长得一样。**
10
+ // · 目录不存在 → 说「目录不存在」
11
+ // · zstd 能力不存在 → 说「这个 Node 不支持 zstd」
12
+ // · 帧解压失败 → 把 failedFrames 报上去
13
+ // 历史上踩过的坑正是这个:`zstd -dc` 管道静默失败,扫出来一堆 0,看着像「没有数据」。
14
+
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import os from 'node:os'
18
+ import zlib from 'node:zlib'
19
+
20
+ /** zstd 帧魔数 0x28 0xB5 0x2F 0xFD。 */
21
+ const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
22
+
23
+ const isMagicAt = (raw, i) => raw[i] === ZSTD_MAGIC[0] && raw[i + 1] === ZSTD_MAGIC[1]
24
+ && raw[i + 2] === ZSTD_MAGIC[2] && raw[i + 3] === ZSTD_MAGIC[3]
25
+
26
+ /** 这个 Node 有没有 zstd 解压能力。没有就明说,不要假装扫过了。 */
27
+ export function hasZstdSupport() {
28
+ return typeof zlib.zstdDecompressSync === 'function'
29
+ }
30
+
31
+ /**
32
+ * 把整个 Buffer 按 zstd 魔数切成帧边界。
33
+ * @param {Buffer} raw
34
+ * @returns {number[]} 每帧的起始偏移(升序)
35
+ */
36
+ export function frameOffsets(raw) {
37
+ const starts = []
38
+ for (let i = 0; i + 4 <= raw.length; i += 1) {
39
+ if (isMagicAt(raw, i)) starts.push(i)
40
+ }
41
+ return starts
42
+ }
43
+
44
+ /**
45
+ * 逐帧解压,产出每帧解压后的文本。
46
+ * @param {Buffer} raw
47
+ * @returns {{texts: string[], frames: number, failedFrames: number}}
48
+ * failedFrames 必须往上报:解压失败的帧意味着事件表缺了一段,
49
+ * 而缺了的那一段在后续统计里会安静地消失 —— 不报就等于假装没发生。
50
+ */
51
+ export function decodeFrames(raw) {
52
+ const starts = frameOffsets(raw)
53
+ const texts = []
54
+ let failedFrames = 0
55
+ for (let k = 0; k < starts.length; k += 1) {
56
+ const begin = starts[k]
57
+ const end = k + 1 < starts.length ? starts[k + 1] : raw.length
58
+ try {
59
+ texts.push(zlib.zstdDecompressSync(raw.subarray(begin, end)).toString('utf8'))
60
+ } catch {
61
+ failedFrames += 1
62
+ }
63
+ }
64
+ return { texts, frames: starts.length, failedFrames }
65
+ }
66
+
67
+ /**
68
+ * 读一个会话日志文件,返回解压后的事件数组。
69
+ * @param {string} file
70
+ * @returns {{events: object[], frames: number, failedFrames: number}}
71
+ */
72
+ export function readSessionEvents(file) {
73
+ const { texts, frames, failedFrames } = decodeFrames(fs.readFileSync(file))
74
+ const events = []
75
+ for (const text of texts) {
76
+ for (const line of text.split('\n')) {
77
+ const t = line.trim()
78
+ if (!t) continue
79
+ // 半行是写入过程被中断留下的,跳过即可 —— 它不是错误,是正常尾部。
80
+ try { events.push(JSON.parse(t)) } catch { /* 半行 */ }
81
+ }
82
+ }
83
+ return { events, frames, failedFrames }
84
+ }
85
+
86
+ /** 会话日志根目录。宿主把它放在 DSH_HOME/sessions 下。 */
87
+ export function sessionsRoot(home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')) {
88
+ return path.join(String(home), 'sessions')
89
+ }
90
+
91
+ /**
92
+ * 列出会话日志文件。
93
+ *
94
+ * @param {object} [opts]
95
+ * @param {string} [opts.root] 会话根目录;默认 sessionsRoot()
96
+ * @param {number} [opts.sinceMs] 只留修改时间晚于这个时刻的文件(省得每个都去解一遍)
97
+ * @returns {{files: Array<{file: string, mtimeMs: number, size: number}>, rootExists: boolean, error: string|null}}
98
+ */
99
+ export function listSessionFiles({ root, sinceMs = 0 } = {}) {
100
+ const dir = root ?? sessionsRoot()
101
+ let rootStat = null
102
+ try { rootStat = fs.statSync(dir) } catch { rootStat = null }
103
+ if (!rootStat || !rootStat.isDirectory()) {
104
+ // 目录不存在是「没有会话可扫」,不是「扫了发现没问题」。必须能分辨。
105
+ return { files: [], rootExists: false, error: `会话目录不存在:${dir}` }
106
+ }
107
+ const out = []
108
+ const walk = (d) => {
109
+ let entries
110
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
111
+ for (const e of entries) {
112
+ const p = path.join(d, e.name)
113
+ if (e.isDirectory()) { walk(p); continue }
114
+ if (!e.name.endsWith('.zstd')) continue
115
+ let st
116
+ try { st = fs.statSync(p) } catch { continue }
117
+ if (sinceMs > 0 && st.mtimeMs < sinceMs) continue
118
+ out.push({ file: p, mtimeMs: st.mtimeMs, size: st.size })
119
+ }
120
+ }
121
+ walk(dir)
122
+ return { files: out, rootExists: true, error: null }
123
+ }
124
+
125
+ /**
126
+ * 宿主事件 → 回扫用的扁平形状。
127
+ *
128
+ * 时间字段实测是 `ev.time`(epoch 毫秒)。曾经按 `ev.at` / `ev.timestamp` 读过,
129
+ * 两者都是 undefined,于是每条线索的出处都是空串 —— 那种线索没法反查,等于没记。
130
+ *
131
+ * @param {object[]} events
132
+ * @returns {Array<{type: string, seq: number|null, at: string, text: string, sourceKind: string}>}
133
+ */
134
+ export function toLeadEvents(events) {
135
+ const out = []
136
+ for (const ev of Array.isArray(events) ? events : []) {
137
+ if (ev?.type !== 'user/message') continue
138
+ const content = ev?.data?.content ?? ev?.data?.message?.content
139
+ const blocks = Array.isArray(content) ? content : []
140
+ const text = blocks
141
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
142
+ .map((b) => b.text)
143
+ .join('\n')
144
+ .trim()
145
+ if (!text) continue
146
+ const ms = typeof ev.time === 'number' ? ev.time : NaN
147
+ out.push({
148
+ type: 'user/message',
149
+ seq: Number.isInteger(ev.seq) ? ev.seq : null,
150
+ at: Number.isFinite(ms) ? new Date(ms).toISOString() : '',
151
+ text,
152
+ sourceKind: ev?.data?.source?.kind ?? ev?.data?.message?.source?.kind ?? '',
153
+ // 任务板下发的卡片也是 `source.kind = 'user'`(rpcId 形如 `task-board-<uuid>`),
154
+ // 但那是卡片提示词,不是人说的话。实测捞出来两条「不要调用任何工具,不要读文件」
155
+ // 全是任务板的 —— 靠 rpcId 前缀能准确分开。
156
+ rpcId: String(ev?.data?.source?.rpcId ?? ev?.data?.message?.source?.rpcId ?? '')
157
+ })
158
+ }
159
+ return out
160
+ }
161
+
162
+ /**
163
+ * 一个会话是不是 subagent 会话。
164
+ *
165
+ * 为什么必须判:subagent 会话里 `source.kind` 也是 `'user'`,但那些「用户发言」
166
+ * 是父 agent 派活时写的任务书(实测有「不要调用任何工具,不要读文件」这种)。
167
+ * 把它们当用户原话捞出来,线索表会变成我自己的笔记。
168
+ *
169
+ * @param {object[]} events
170
+ * @returns {boolean}
171
+ */
172
+ export function isSubagentSession(events) {
173
+ return (Array.isArray(events) ? events : []).some((e) => e?.type === 'subagent/descriptor')
174
+ }