@zaofan/dsh-qqbot 1.10.1 → 1.10.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.
- package/docs/USER-GUIDE.md +16 -0
- package/examples/ext-tools/lib/session_watch_lib.mjs +844 -767
- package/examples/ext-tools/session_watch.mjs +163 -139
- package/package.json +1 -1
|
@@ -1,767 +1,844 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* session_watch_lib.mjs — 「回合结束回灌」的共用实现(放在 tools/lib/ 下,不被当工具扫描)
|
|
3
|
-
*
|
|
4
|
-
* 口径**照抄 qqbot 的 passive 收尾**(src/transport/outbound.ts:80-211):
|
|
5
|
-
* · 回合内累积 `assistant/message` 的正文(= 它的 noteGenerated(sessionKey, msgId, text))
|
|
6
|
-
* · `turn/end` 时结算(它的注释原文:「assistant/message 用完即清,turn/end 兜底清」)
|
|
7
|
-
* · 取**最后一个正文块**的文本(它收尾时也是"把最后一块复制一份")
|
|
8
|
-
*
|
|
9
|
-
* ★ 2026-10-12 主人定案(回灌模式三档 + 转发到 QQ + 引用回流):
|
|
10
|
-
* **一档(mode=1,默认)**:只把**末段正文**回灌给父会话(原行为),**同时**转发到父会话所在的 QQ。
|
|
11
|
-
* **二档(mode=2)**:本回合的**所有正文块 + 报错**都转发到 QQ,但**不进父会话上下文**。
|
|
12
|
-
* **三档(mode=3)**:所有正文 + 报错**进**父会话上下文(**只插话、不唤醒**),同时也转发到 QQ。
|
|
13
|
-
*
|
|
14
|
-
* ★ 转发到 QQ 的三条口径:
|
|
15
|
-
* ① **行首归属**:每条转发消息的第一行是 `【子会话 xxxx】`(xxxx=会话短 id)。
|
|
16
|
-
* 它**短且在最前**是硬要求 —— QQ 入站引用块只带被引原文**前 60 字**(inbound 的 QUOTE_KEEP),
|
|
17
|
-
* 归属跑出这 60 字就再也认不出来了(见下面的"引用回流")。
|
|
18
|
-
* ② **markdown + 失败降级**:直接走 `sender.sendMarkdown(replyTarget, text)` ——
|
|
19
|
-
* 插件自带的 markdown→纯文本降级链(`send_md` 工具用的就是它),失败再退回 `sender.sendText`。
|
|
20
|
-
* ③ **被动优先**:`replyTarget` 自带该会话最近的 `msgId`,带它发即"被动回复"
|
|
21
|
-
* (不吃主动配额、QQ 也不吞);插件侧在被动失败时会自动去掉 msgId 转主动。
|
|
22
|
-
* ⚠️ 这两件事都在**插件**里做好了,本库直接用 `sender` + `replyTarget` 即可,不重复造。
|
|
23
|
-
*
|
|
24
|
-
* ★ 引用回流(主人 2026-10-12 明确口径:**靠行首会话信息判断,不依赖台账**):
|
|
25
|
-
* 群里有人**引用**那条转发消息时,入站引用块会把被引原文的前 60 字带进来,
|
|
26
|
-
* 归属行就在里面 ⇒ 本库从入站 user 消息里正则认出 `【子会话 xxxx】`
|
|
27
|
-
* → 把这条引用的原文**插话**给对应子会话(忙时 inject、空闲 followup)。
|
|
28
|
-
* **不需要 QQ 的 msg_id**,也**不需要维护任何 msg_id→会话 的映射表**。
|
|
29
|
-
* 短 id → 完整会话 id 是**当场**在会话注册表里查的(见 resolveShortId)。
|
|
30
|
-
*
|
|
31
|
-
* 本库只做三件事:挂监听、结算回灌/转发、认引用回流。
|
|
32
|
-
*
|
|
33
|
-
* ★ 2026-10-12 定案(主人):回灌**模拟用户消息**(source.kind='user')+ **唤醒**父会话(一档)。
|
|
34
|
-
* `followup` 就是 `inject` 的唤醒档(inject=next-step 不唤醒;followup=next-turn 唤醒),
|
|
35
|
-
* 所以"注入 + 唤醒"用 followup。
|
|
36
|
-
* 原因(实测结论):`inject` 落 `agent/inbox/spliced **target=next-step**` —— 排队到下次组包、
|
|
37
|
-
* **不产生回合**,因此既没有 turn 触发节点、**UI 上也什么都没有**。
|
|
38
|
-
* 而 `followup` 落 `target=next-turn` ⇒ 产生回合 ⇒ 被渲染成 `TurnTriggerNodeView`(那个「白框」)。
|
|
39
|
-
* 代价:回灌会**唤醒父会话跑一轮**(烧 token),故正文里加了一行"无需回复"提示。
|
|
40
|
-
* → 三档(mode=3)要的是"进上下文但**不唤醒**",所以那一档**强制走 inject**。
|
|
41
|
-
*
|
|
42
|
-
* ⚠️ 回灌消息的形状**必须**对齐宿主「子会话结算通知」(createSettlementMessage):
|
|
43
|
-
* { id, role:'user', content:[摘要行, 结尾正文引导语, ...正文块],
|
|
44
|
-
* source:{ kind:'subagent-settled', form:'notice', summary, senderSessionId } }
|
|
45
|
-
*
|
|
46
|
-
* ⚠️ 注入前必须过 isInjectableMessage() 自检(fail-closed)。宿主的顺序是
|
|
47
|
-
* **先 append 落盘、后读 `msg.source.kind`**:形状不合法时坏记录已经写进
|
|
48
|
-
* `agent/inbox/spliced.data.inserted[]`,之后投影抛错 —— 会话文件从此无法加载。
|
|
49
|
-
* 事故实例:会话 9643a885 的 seq 4222 曾被写成 `inserted: ["<字符串>"]`。
|
|
50
|
-
*
|
|
51
|
-
* 用法:
|
|
52
|
-
* import { register } from './lib/session_watch_lib.mjs';
|
|
53
|
-
* await register(root, { parentAgentId, childSessionId, label, mode, sender, replyTarget });
|
|
54
|
-
*/
|
|
55
|
-
/**
|
|
56
|
-
* 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
|
|
57
|
-
* 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
|
|
58
|
-
*/
|
|
59
|
-
export const __LIB_VERSION__ = 'lib-v12-20261012-归属行带会话名/预设/工作区';
|
|
60
|
-
|
|
61
|
-
// ── ★ 2026-10-13 持久化改造(主人指示:**以本地存档为准,别只靠内存**)──────────────
|
|
62
|
-
// 本文件的风格是"用到时动态 import";这里用**顶层 await** 一次性取到同步版 fs,
|
|
63
|
-
// 后面的 ensureLoaded()(同步读存档)才能被同步函数(如 listWatching)直接调用。
|
|
64
|
-
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
65
|
-
const { dirname } = await import('node:path');
|
|
66
|
-
const { fileURLToPath } = await import('node:url');
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 盯梢名单。**放在 globalThis 上**,不是模块局部变量。
|
|
70
|
-
*
|
|
71
|
-
* 原因:`tools/lib/` 的 import 带了 `?v=<mtime>` 版本号(为让热刷生效),
|
|
72
|
-
* 于是每改一次本文件就会产生一个**新的模块实例**。若名单是模块局部的,
|
|
73
|
-
* 改一次 lib 就把已登记的盯梢对象全丢了。放 globalThis 上跨实例共享。
|
|
74
|
-
*/
|
|
75
|
-
const WATCHING_KEY = Symbol.for('dsh-qqbot.session-watch.watching');
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
if (typeof
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
*/
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
async function
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
if (!
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1
|
+
/**
|
|
2
|
+
* session_watch_lib.mjs — 「回合结束回灌」的共用实现(放在 tools/lib/ 下,不被当工具扫描)
|
|
3
|
+
*
|
|
4
|
+
* 口径**照抄 qqbot 的 passive 收尾**(src/transport/outbound.ts:80-211):
|
|
5
|
+
* · 回合内累积 `assistant/message` 的正文(= 它的 noteGenerated(sessionKey, msgId, text))
|
|
6
|
+
* · `turn/end` 时结算(它的注释原文:「assistant/message 用完即清,turn/end 兜底清」)
|
|
7
|
+
* · 取**最后一个正文块**的文本(它收尾时也是"把最后一块复制一份")
|
|
8
|
+
*
|
|
9
|
+
* ★ 2026-10-12 主人定案(回灌模式三档 + 转发到 QQ + 引用回流):
|
|
10
|
+
* **一档(mode=1,默认)**:只把**末段正文**回灌给父会话(原行为),**同时**转发到父会话所在的 QQ。
|
|
11
|
+
* **二档(mode=2)**:本回合的**所有正文块 + 报错**都转发到 QQ,但**不进父会话上下文**。
|
|
12
|
+
* **三档(mode=3)**:所有正文 + 报错**进**父会话上下文(**只插话、不唤醒**),同时也转发到 QQ。
|
|
13
|
+
*
|
|
14
|
+
* ★ 转发到 QQ 的三条口径:
|
|
15
|
+
* ① **行首归属**:每条转发消息的第一行是 `【子会话 xxxx】`(xxxx=会话短 id)。
|
|
16
|
+
* 它**短且在最前**是硬要求 —— QQ 入站引用块只带被引原文**前 60 字**(inbound 的 QUOTE_KEEP),
|
|
17
|
+
* 归属跑出这 60 字就再也认不出来了(见下面的"引用回流")。
|
|
18
|
+
* ② **markdown + 失败降级**:直接走 `sender.sendMarkdown(replyTarget, text)` ——
|
|
19
|
+
* 插件自带的 markdown→纯文本降级链(`send_md` 工具用的就是它),失败再退回 `sender.sendText`。
|
|
20
|
+
* ③ **被动优先**:`replyTarget` 自带该会话最近的 `msgId`,带它发即"被动回复"
|
|
21
|
+
* (不吃主动配额、QQ 也不吞);插件侧在被动失败时会自动去掉 msgId 转主动。
|
|
22
|
+
* ⚠️ 这两件事都在**插件**里做好了,本库直接用 `sender` + `replyTarget` 即可,不重复造。
|
|
23
|
+
*
|
|
24
|
+
* ★ 引用回流(主人 2026-10-12 明确口径:**靠行首会话信息判断,不依赖台账**):
|
|
25
|
+
* 群里有人**引用**那条转发消息时,入站引用块会把被引原文的前 60 字带进来,
|
|
26
|
+
* 归属行就在里面 ⇒ 本库从入站 user 消息里正则认出 `【子会话 xxxx】`
|
|
27
|
+
* → 把这条引用的原文**插话**给对应子会话(忙时 inject、空闲 followup)。
|
|
28
|
+
* **不需要 QQ 的 msg_id**,也**不需要维护任何 msg_id→会话 的映射表**。
|
|
29
|
+
* 短 id → 完整会话 id 是**当场**在会话注册表里查的(见 resolveShortId)。
|
|
30
|
+
*
|
|
31
|
+
* 本库只做三件事:挂监听、结算回灌/转发、认引用回流。
|
|
32
|
+
*
|
|
33
|
+
* ★ 2026-10-12 定案(主人):回灌**模拟用户消息**(source.kind='user')+ **唤醒**父会话(一档)。
|
|
34
|
+
* `followup` 就是 `inject` 的唤醒档(inject=next-step 不唤醒;followup=next-turn 唤醒),
|
|
35
|
+
* 所以"注入 + 唤醒"用 followup。
|
|
36
|
+
* 原因(实测结论):`inject` 落 `agent/inbox/spliced **target=next-step**` —— 排队到下次组包、
|
|
37
|
+
* **不产生回合**,因此既没有 turn 触发节点、**UI 上也什么都没有**。
|
|
38
|
+
* 而 `followup` 落 `target=next-turn` ⇒ 产生回合 ⇒ 被渲染成 `TurnTriggerNodeView`(那个「白框」)。
|
|
39
|
+
* 代价:回灌会**唤醒父会话跑一轮**(烧 token),故正文里加了一行"无需回复"提示。
|
|
40
|
+
* → 三档(mode=3)要的是"进上下文但**不唤醒**",所以那一档**强制走 inject**。
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ 回灌消息的形状**必须**对齐宿主「子会话结算通知」(createSettlementMessage):
|
|
43
|
+
* { id, role:'user', content:[摘要行, 结尾正文引导语, ...正文块],
|
|
44
|
+
* source:{ kind:'subagent-settled', form:'notice', summary, senderSessionId } }
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ 注入前必须过 isInjectableMessage() 自检(fail-closed)。宿主的顺序是
|
|
47
|
+
* **先 append 落盘、后读 `msg.source.kind`**:形状不合法时坏记录已经写进
|
|
48
|
+
* `agent/inbox/spliced.data.inserted[]`,之后投影抛错 —— 会话文件从此无法加载。
|
|
49
|
+
* 事故实例:会话 9643a885 的 seq 4222 曾被写成 `inserted: ["<字符串>"]`。
|
|
50
|
+
*
|
|
51
|
+
* 用法:
|
|
52
|
+
* import { register } from './lib/session_watch_lib.mjs';
|
|
53
|
+
* await register(root, { parentAgentId, childSessionId, label, mode, sender, replyTarget });
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
|
|
57
|
+
* 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
|
|
58
|
+
*/
|
|
59
|
+
export const __LIB_VERSION__ = 'lib-v12-20261012-归属行带会话名/预设/工作区';
|
|
60
|
+
|
|
61
|
+
// ── ★ 2026-10-13 持久化改造(主人指示:**以本地存档为准,别只靠内存**)──────────────
|
|
62
|
+
// 本文件的风格是"用到时动态 import";这里用**顶层 await** 一次性取到同步版 fs,
|
|
63
|
+
// 后面的 ensureLoaded()(同步读存档)才能被同步函数(如 listWatching)直接调用。
|
|
64
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
65
|
+
const { dirname } = await import('node:path');
|
|
66
|
+
const { fileURLToPath } = await import('node:url');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 盯梢名单。**放在 globalThis 上**,不是模块局部变量。
|
|
70
|
+
*
|
|
71
|
+
* 原因:`tools/lib/` 的 import 带了 `?v=<mtime>` 版本号(为让热刷生效),
|
|
72
|
+
* 于是每改一次本文件就会产生一个**新的模块实例**。若名单是模块局部的,
|
|
73
|
+
* 改一次 lib 就把已登记的盯梢对象全丢了。放 globalThis 上跨实例共享。
|
|
74
|
+
*/
|
|
75
|
+
const WATCHING_KEY = Symbol.for('dsh-qqbot.session-watch.watching');
|
|
76
|
+
/**
|
|
77
|
+
* ★ 2026-10-13:**扩展工具侧注入的 sender/replyTarget**(跨模块实例共享)。
|
|
78
|
+
* 工具 env 里**一定有**这两样(扩展工具 env = `{cwd, manager, sender, replyTarget, exec, ctx, logger}`),
|
|
79
|
+
* 而插件根 ctx(handler 拿到的 root)上实测**拿不到** sender ⇒ 单靠 root 补不回 QQ 转发。
|
|
80
|
+
* 所以让扩展工具每次被调用时顺手写进来,handler 侧再读它去补 fwd。
|
|
81
|
+
*/
|
|
82
|
+
const FWD_KEY = Symbol.for('dsh-qqbot.session-watch.fwd');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 供扩展工具调用:把本次 env 的 sender/replyTarget 交给回灌库。
|
|
86
|
+
* 调一次之后,回灌库在"父会话发言"时就能把 QQ 转发能力补回来(重启后尤其需要)。
|
|
87
|
+
* @returns {boolean} 是否记住
|
|
88
|
+
*/
|
|
89
|
+
export function rememberFwd(sender, replyTarget) {
|
|
90
|
+
try {
|
|
91
|
+
if (sender && replyTarget) {
|
|
92
|
+
globalThis[FWD_KEY] = { sender, replyTarget };
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
} catch { return false; }
|
|
97
|
+
}
|
|
98
|
+
const watching = (globalThis[WATCHING_KEY] ??= new Map()); // childSessionId -> entry
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 默认存档路径:`{dataRoot}/.qqbot-extensions/tools/data/session-watch-watching.json`
|
|
102
|
+
* (**与 `vote.json` 同一个 data 目录** —— 对齐既有约定:扩展各类的存档都放
|
|
103
|
+
* `{数据根}/.qqbot-extensions/<类型>/data/<名字>.json`)。
|
|
104
|
+
* 本文件在 `.../tools/lib/` ⇒ `../data/` 正好是 `tools/data/`。
|
|
105
|
+
* 为什么要兜底:`setStateFile()` 此前**全项目无人调用** ⇒ `stateFileOf()` 恒为 '' ⇒
|
|
106
|
+
* `persist()` 里 `if (!file) return` 直接空转 ⇒ **名单从来没落过盘**(重启自然恢复不了)。
|
|
107
|
+
*/
|
|
108
|
+
const DEFAULT_STATE_FILE = (() => {
|
|
109
|
+
try { return fileURLToPath(new URL('../data/session-watch-watching.json', import.meta.url)); }
|
|
110
|
+
catch { return ''; }
|
|
111
|
+
})();
|
|
112
|
+
|
|
113
|
+
let loadedOnce = false;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ★ 持久化读回(主人 2026-10-13 指示:**以本地存档为准,不要只靠内存**)。
|
|
117
|
+
* 幂等:只从文件读一次;此后内存是运行期缓存,所有写操作都会 persist() 回文件。
|
|
118
|
+
* 同步实现 ⇒ 可被 listWatching() / handler() 这类同步路径直接调用。
|
|
119
|
+
* @returns 读回后内存里的条目数
|
|
120
|
+
*/
|
|
121
|
+
export function ensureLoaded() {
|
|
122
|
+
if (loadedOnce) return watching.size;
|
|
123
|
+
loadedOnce = true;
|
|
124
|
+
try {
|
|
125
|
+
const file = stateFileOf();
|
|
126
|
+
if (!file) return watching.size;
|
|
127
|
+
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
128
|
+
const rows = Array.isArray(j?.rows) ? j.rows : [];
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
const child = String(r?.child ?? '').trim();
|
|
131
|
+
const parent = String(r?.parent ?? '').trim();
|
|
132
|
+
if (!child || !parent) continue;
|
|
133
|
+
if (watching.has(child)) continue;
|
|
134
|
+
watching.set(child, {
|
|
135
|
+
parentAgentId: parent,
|
|
136
|
+
label: String(r?.label ?? ''),
|
|
137
|
+
mode: Number(r?.mode) === 2 ? 2 : (Number(r?.mode) === 3 ? 3 : 1),
|
|
138
|
+
shortId: shortSessionId(child),
|
|
139
|
+
name: String(r?.name ?? r?.label ?? ''),
|
|
140
|
+
preset: String(r?.preset ?? ''),
|
|
141
|
+
workspace: String(r?.workspace ?? ''),
|
|
142
|
+
peerName: String(r?.peerName ?? ''),
|
|
143
|
+
// ⚠️ 重启后拿不到 sender/replyTarget ⇒ 先恢复"回灌父会话",
|
|
144
|
+
// QQ 转发等下次 register(或主人重挂)时补上 ⇒ 这里如实置空、不假装就绪。
|
|
145
|
+
fwd: null,
|
|
146
|
+
lastText: '', texts: [], errors: [], seen: 0, firedAt: 0, lastForwarded: '',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} catch { /* 读不到就当空名单 */ }
|
|
150
|
+
return watching.size;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ★ 启动恢复(给启动钩子用):读回存档 + **重装监听器**。
|
|
155
|
+
* 名单以文件为准;而监听器(`root.on('session/event')`)是内存态、重启必丢 ⇒ 只能靠这里重装。
|
|
156
|
+
*/
|
|
157
|
+
export async function rehydrate(root) {
|
|
158
|
+
const n = ensureLoaded();
|
|
159
|
+
try { installHook(root); } catch { /* 装不上就只恢复名单 */ }
|
|
160
|
+
// ★ 2026-10-13:顺手把 QQ 转发能力也补上 —— 只要 `session_watch` 工具刚把 sender/replyTarget
|
|
161
|
+
// 注入过(rememberFwd),这里就能立刻补回,**不必等父会话下一次发言**。
|
|
162
|
+
let fwdFixed = 0;
|
|
163
|
+
try {
|
|
164
|
+
for (const [, w] of watching) if (hydrateFwd(w, root, null)) fwdFixed += 1;
|
|
165
|
+
} catch { /* ignore */ }
|
|
166
|
+
return { restored: n, hooked, fwdFixed };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 盯梢名单落盘(2026-10-12 新增)。
|
|
171
|
+
*
|
|
172
|
+
* 为什么要:名单原本只在内存里,**dsh 一重启,之前 attach 的盯梢全没了**。
|
|
173
|
+
* 落盘后由启动钩子(`.qqbot-extensions/hooks/`)在宿主启动时读回来重新登记 ——
|
|
174
|
+
* 这正是主人要的"让用户写一些在运行 dsh 时启动的东西"的第一个用例。
|
|
175
|
+
*/
|
|
176
|
+
const STATE_KEY = Symbol.for('dsh-qqbot.session-watch.stateFile');
|
|
177
|
+
// ★ 兜底默认路径:没人调 setStateFile() 时也能落盘/读回(见上面 DEFAULT_STATE_FILE 的说明)
|
|
178
|
+
const stateFileOf = () => String(globalThis[STATE_KEY] || DEFAULT_STATE_FILE || '');
|
|
179
|
+
|
|
180
|
+
/** 记下状态文件路径(由 register 的调用方给;没给就不落盘) */
|
|
181
|
+
export function setStateFile(p) {
|
|
182
|
+
try { globalThis[STATE_KEY] = String(p || ''); } catch { /* ignore */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 把当前名单落盘(静默失败:落不了盘只影响"重启后自动恢复",不影响回灌本身) */
|
|
186
|
+
async function persist() {
|
|
187
|
+
const file = stateFileOf();
|
|
188
|
+
if (!file) return;
|
|
189
|
+
try {
|
|
190
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
191
|
+
const { dirname } = await import('node:path');
|
|
192
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
193
|
+
const rows = [...watching.entries()].map(([child, w]) => ({
|
|
194
|
+
child,
|
|
195
|
+
parent: w.parentAgentId,
|
|
196
|
+
label: w.label ?? '',
|
|
197
|
+
name: w.name ?? '',
|
|
198
|
+
preset: w.preset ?? '',
|
|
199
|
+
workspace: w.workspace ?? '',
|
|
200
|
+
mode: w.mode ?? 1,
|
|
201
|
+
peerName: w.peerName ?? '',
|
|
202
|
+
}));
|
|
203
|
+
writeFileSync(file, JSON.stringify({ v: 1, at: Date.now(), rows }, null, 2), 'utf8');
|
|
204
|
+
} catch { /* ignore */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 读回落盘的名单(给启动钩子用;返回原始行数组) */
|
|
208
|
+
export async function loadPersisted() {
|
|
209
|
+
const file = stateFileOf();
|
|
210
|
+
if (!file) return [];
|
|
211
|
+
try {
|
|
212
|
+
const { readFileSync } = await import('node:fs');
|
|
213
|
+
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
214
|
+
return Array.isArray(j?.rows) ? j.rows : [];
|
|
215
|
+
} catch { return []; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 回灌钩子总开关。false = 正常挂 `session/event` 监听。 */
|
|
219
|
+
const HOOK_DISABLED = false;
|
|
220
|
+
|
|
221
|
+
/** 监听只装一次(跨模块实例去重);handler 指针让新实例能立刻接管旧监听。 */
|
|
222
|
+
const HOOK_KEY = Symbol.for('dsh-qqbot.session-watch.hook');
|
|
223
|
+
const HANDLER_KEY = Symbol.for('dsh-qqbot.session-watch.handler');
|
|
224
|
+
let hooked = false;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* turn/end 的 data.reason → 人话。
|
|
228
|
+
* 口径来自 webui 的两条文案(dsh-client-ui-chat/lib/client.js):
|
|
229
|
+
* "message.turnProcess.worked" = 已完成 ← 正常跑完
|
|
230
|
+
* "message.stopped" = 已停止 ← 被停止/中断
|
|
231
|
+
* 宿主在 index.js:912 把 event.data.reason 记进 inflight.endReason,webui 据此显示。
|
|
232
|
+
*/
|
|
233
|
+
function reasonText(reason) {
|
|
234
|
+
const r = String(reason ?? '').toLowerCase();
|
|
235
|
+
if (!r) return '结束';
|
|
236
|
+
if (/stop|abort|cancel|interrupt|halt/.test(r)) return '已停止';
|
|
237
|
+
if (/complet|done|finish|success|ok/.test(r)) return '已完成';
|
|
238
|
+
if (/error|fail/.test(r)) return '出错';
|
|
239
|
+
return r; // 未知取值就原样带出来,便于以后补映射
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `notice` 摘要行的长度上限,与宿主 `dsh-llm` 的 CONTEXT_SUMMARY_MAX_CHARS 保持一致。
|
|
244
|
+
* 宿主原文注释:producer 的一行说明会写进持久日志,所以要限长。
|
|
245
|
+
*/
|
|
246
|
+
const CONTEXT_SUMMARY_MAX_CHARS = 120;
|
|
247
|
+
|
|
248
|
+
/** 把摘要行压到上限内(超出用省略号),口径照抄宿主 dsh-llm 的 boundContextSummary()。 */
|
|
249
|
+
function boundContextSummary(summary) {
|
|
250
|
+
const t = String(summary ?? '');
|
|
251
|
+
return t.length <= CONTEXT_SUMMARY_MAX_CHARS ? t : `${t.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 取会话短 id 用于显示。
|
|
256
|
+
* ⚠️ 子会话 id 形如 `session-f147236a-…`,直接 slice(0,8) 只会得到无意义的 `session-`。
|
|
257
|
+
* 所以先剥掉 `session-` 前缀再截断。
|
|
258
|
+
*/
|
|
259
|
+
export function shortSessionId(id) {
|
|
260
|
+
const s = String(id ?? '');
|
|
261
|
+
return (s.startsWith('session-') ? s.slice(8) : s).slice(0, 8);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 行首归属标记(2026-10-12 主人定:除了短 id,还要**会话名 / 预设名 / 工作区**)。
|
|
266
|
+
*
|
|
267
|
+
* ⚠️ 排版有硬约束:QQ 入站引用块只带被引原文**前 60 字**,而引用回流就靠这一行认路。
|
|
268
|
+
* 所以顺序是**路由键打头、其余信息跟在后面** —— 即使后面被截掉,`【子会话 xxxx` 也稳稳留在前 15 字内。
|
|
269
|
+
* 路由键后面的 `】` **不保证存在**(可能正好被截断),因此解析正则**不要求右括号**。
|
|
270
|
+
*
|
|
271
|
+
* 例:`【子会话 87c87c3b · 回灌转发自测 · standard · 鲸鱼娘】`(约 40 字,安全)
|
|
272
|
+
*/
|
|
273
|
+
export function ownerLine(parts) {
|
|
274
|
+
const p = (typeof parts === 'string') ? { shortId: parts } : (parts ?? {});
|
|
275
|
+
const seg = [`【子会话 ${String(p.shortId ?? '').slice(0, 8)}`];
|
|
276
|
+
for (const v of [p.name, p.preset, p.workspace]) {
|
|
277
|
+
const s = String(v ?? '').trim();
|
|
278
|
+
if (s) seg.push(s.replace(/[·】\n]/g, ' ').slice(0, 24)); // 别让分隔符/换行混进来
|
|
279
|
+
}
|
|
280
|
+
return seg.join(' · ') + '】';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 认出归属标记(引用回流的路由键)。
|
|
285
|
+
* **故意不要求右括号** —— 引用块只带前 60 字,归属行尾可能被截掉。
|
|
286
|
+
* 同时容忍中间的空格与全角/半角差异。
|
|
287
|
+
*/
|
|
288
|
+
const OWNER_RE = /【\s*子会话\s*([0-9a-fA-F]{4,12})/;
|
|
289
|
+
|
|
290
|
+
/** 从一段文本里抽归属短 id(找不到返回 '') */
|
|
291
|
+
export function parseOwner(text) {
|
|
292
|
+
const m = OWNER_RE.exec(String(text ?? ''));
|
|
293
|
+
return m ? m[1].toLowerCase() : '';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 短 id → 完整会话 id。**当场查**,不依赖任何持久台账(主人 2026-10-12 明确口径)。
|
|
298
|
+
* 会话注册表的 API 形状随版本变,这里逐种试,全部失败就返回 ''。
|
|
299
|
+
*/
|
|
300
|
+
export function resolveShortId(root, short) {
|
|
301
|
+
const want = String(short ?? '').toLowerCase();
|
|
302
|
+
if (!want) return '';
|
|
303
|
+
const hit = (id) => {
|
|
304
|
+
const s = String(id ?? '');
|
|
305
|
+
return s && shortSessionId(s).toLowerCase().startsWith(want) ? s : '';
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
// ① 最快的一条:agents 服务上按 id 前缀找(若提供了 list)
|
|
309
|
+
const svc = (k) => { try { return root?.get?.(k); } catch { return undefined; } };
|
|
310
|
+
for (const key of ['sessions', 'agents']) {
|
|
311
|
+
const s = svc(key);
|
|
312
|
+
if (!s) continue;
|
|
313
|
+
const bags = [];
|
|
314
|
+
if (typeof s.list === 'function') { try { bags.push(s.list()); } catch { /* ignore */ } }
|
|
315
|
+
if (typeof s.values === 'function') { try { bags.push([...s.values()]); } catch { /* ignore */ } }
|
|
316
|
+
if (typeof s.all === 'function') { try { bags.push(s.all()); } catch { /* ignore */ } }
|
|
317
|
+
if (Array.isArray(s)) bags.push(s);
|
|
318
|
+
for (const bag of bags) {
|
|
319
|
+
const arr = Array.isArray(bag) ? bag : (bag && typeof bag === 'object' ? Object.values(bag) : []);
|
|
320
|
+
for (const it of arr) {
|
|
321
|
+
const id = String(it?.id ?? it?.sessionId ?? it ?? '');
|
|
322
|
+
const got = hit(id);
|
|
323
|
+
if (got) return got;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch { /* ignore */ }
|
|
328
|
+
return '';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 把 `turn/end` 的 reason 归一成字符串。
|
|
333
|
+
*
|
|
334
|
+
* ⚠️ 2026-10-12 实测:`event.data.reason` **不是字符串而是对象**,形如
|
|
335
|
+
* `{kind:'completed'}` 或 `{kind:'aborted', reason:{kind:'user'}}`。
|
|
336
|
+
* 旧代码直接 `String(reason)` ⇒ 摘要行里出现 `回合[object object](reason=[object Object])`。
|
|
337
|
+
* 本函数优先取最外层的 `kind`,没有就下钻内层 `reason`,都不是才退回 String()。
|
|
338
|
+
*/
|
|
339
|
+
export function reasonKind(value) {
|
|
340
|
+
if (value === null || value === undefined) return '';
|
|
341
|
+
if (typeof value === 'string') return value;
|
|
342
|
+
if (typeof value === 'object') {
|
|
343
|
+
if (typeof value.kind === 'string' && value.kind) return value.kind;
|
|
344
|
+
if (value.reason !== undefined && value.reason !== value) return reasonKind(value.reason);
|
|
345
|
+
if (typeof value.status === 'string' && value.status) return value.status;
|
|
346
|
+
if (typeof value.stopReason === 'string' && value.stopReason) return value.stopReason;
|
|
347
|
+
}
|
|
348
|
+
return String(value);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 把各种可能的 content 形状压成纯文本。
|
|
353
|
+
*
|
|
354
|
+
* ★ 2026-10-12 修(主人实测):**只取 `type === 'text'` 的正文块**。
|
|
355
|
+
* 早先的过滤条件是 `x.type === 'text' || typeof x.text === 'string'` ——
|
|
356
|
+
* 而 **reasoning(思考)块同样带 `.text` 字符串**,于是思考被一起并进来,
|
|
357
|
+
* 回灌的"结尾正文"里冒出一整段英文思考(子会话 3bee83ac 那次实测)。
|
|
358
|
+
* reasoning / tool-call 等块一律丢弃。
|
|
359
|
+
*/
|
|
360
|
+
function asText(c) {
|
|
361
|
+
if (typeof c === 'string') return c.trim();
|
|
362
|
+
if (Array.isArray(c)) {
|
|
363
|
+
return c
|
|
364
|
+
.filter((x) => x && x.type === 'text' && typeof x.text === 'string')
|
|
365
|
+
.map((x) => String(x.text))
|
|
366
|
+
.join('\n')
|
|
367
|
+
.trim();
|
|
368
|
+
}
|
|
369
|
+
if (c && typeof c === 'object') {
|
|
370
|
+
if (c.type === 'reasoning') return ''; // 思考块不算正文
|
|
371
|
+
if (typeof c.text === 'string') return String(c.text).trim();
|
|
372
|
+
if (Array.isArray(c.blocks)) return asText(c.blocks); // 有些版本正文藏在 blocks 里
|
|
373
|
+
}
|
|
374
|
+
return '';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 从事件里尽力抽正文(assistant/message 的形状随版本变,逐层试) */
|
|
378
|
+
function textOf(ev) {
|
|
379
|
+
const cands = [
|
|
380
|
+
ev?.data?.message?.content,
|
|
381
|
+
ev?.data?.content,
|
|
382
|
+
ev?.message?.content,
|
|
383
|
+
ev?.data?.text,
|
|
384
|
+
ev?.text,
|
|
385
|
+
ev?.data?.message?.text,
|
|
386
|
+
];
|
|
387
|
+
for (const c of cands) {
|
|
388
|
+
const t = asText(c);
|
|
389
|
+
if (t) return t;
|
|
390
|
+
}
|
|
391
|
+
return '';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 兜底:直接从会话实例取"最后一条助手消息"的正文 */
|
|
395
|
+
function lastAssistantText(root, sid) {
|
|
396
|
+
try {
|
|
397
|
+
const sess = root.get('sessions')?.get?.(sid);
|
|
398
|
+
const msgs = sess?.deriveMessages?.() ?? [];
|
|
399
|
+
for (let i = msgs.length - 1; i >= 0; i -= 1) {
|
|
400
|
+
const m = msgs[i];
|
|
401
|
+
const role = String(m?.role ?? m?.type ?? '');
|
|
402
|
+
if (role.includes('assistant')) {
|
|
403
|
+
const t = asText(m?.content);
|
|
404
|
+
if (t) return t;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch { /* ignore */ }
|
|
408
|
+
return '';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 注入前的形状自检(fail-closed)。
|
|
413
|
+
*
|
|
414
|
+
* 复刻宿主 v4 的准入条件(dsh-session-persistence-jsonl/lib/worker.cjs):
|
|
415
|
+
* ① `agent/inbox/spliced` 的 `inserted[]` 每项必须 `isSessionFormatJsonObject`;
|
|
416
|
+
* ② 每项的 `source` 必须是对象、`kind` 为非空字符串、且 !== 'plugin'
|
|
417
|
+
* (源码原文:`format v4 message requires a producer-owned source kind`)。
|
|
418
|
+
*
|
|
419
|
+
* 为什么必须自检而不是交给 inject():宿主是**先 append 落盘、后读 source.kind** 的顺序。
|
|
420
|
+
* 传进非对象时,坏记录已经写进 `inserted[]`,之后投影抛错 —— 文件从此无法加载。
|
|
421
|
+
* 事故实例:会话 9643a885 的 seq 4222 被写成 `inserted: ["<字符串>"]`,整个会话读不出来。
|
|
422
|
+
*/
|
|
423
|
+
export function isInjectableMessage(m) {
|
|
424
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) return false;
|
|
425
|
+
if (typeof m.role !== 'string' || !m.role) return false;
|
|
426
|
+
if (!Array.isArray(m.content) || m.content.length === 0) return false;
|
|
427
|
+
const src = m.source;
|
|
428
|
+
if (!src || typeof src !== 'object' || Array.isArray(src)) return false;
|
|
429
|
+
if (typeof src.kind !== 'string' || !src.kind || src.kind === 'plugin') return false;
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 造一条纯文本 user 消息(供 inject / followup 用;形状已过 isInjectableMessage) */
|
|
434
|
+
function userText(text) {
|
|
435
|
+
return {
|
|
436
|
+
id: (globalThis.crypto?.randomUUID?.() ?? ('inj-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8))),
|
|
437
|
+
role: 'user',
|
|
438
|
+
content: [{ type: 'text', text: String(text ?? '') }],
|
|
439
|
+
source: { kind: 'user' },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 构造一条「子会话结算通知」形状的回灌消息(纯函数,便于单测)。
|
|
445
|
+
*
|
|
446
|
+
* 形状**对齐宿主** dsh-subagent/lib/index.js 的 `createSettlementMessage()`:
|
|
447
|
+
* source : { kind:'subagent-settled', form:'notice', summary, senderSessionId }
|
|
448
|
+
* content: [ 摘要行, 结尾正文引导语, ...正文块 ],role 由 createUserMessage 固定为 'user'
|
|
449
|
+
*
|
|
450
|
+
* 采用宿主形状的三个理由:
|
|
451
|
+
* ① kind 是宿主自有的 producer-owned 值,不再冒充真人 user 发言;
|
|
452
|
+
* ② senderSessionId 明确归属到具体子会话,UI 侧归进**子任务**那条通道而不是混进
|
|
453
|
+
* 用户真实发言流里 —— 这与上一轮「回灌插错位置」是同一类隐患;
|
|
454
|
+
* ③ summary 与宿主同口径(dsh-llm CONTEXT_SUMMARY_MAX_CHARS = 120)。
|
|
455
|
+
*
|
|
456
|
+
* @param {{sessionId?: string, label?: string, rawReason?: string, text?: string}} input
|
|
457
|
+
* @returns 可直接交给 agent.inject() 的完整消息对象
|
|
458
|
+
*/
|
|
459
|
+
export function buildSettlementNotice(input = {}) {
|
|
460
|
+
const sid = String(input.sessionId ?? '');
|
|
461
|
+
const label = String(input.label ?? '');
|
|
462
|
+
const rawReason = String(input.rawReason ?? '');
|
|
463
|
+
const stage = reasonText(rawReason);
|
|
464
|
+
const summary = boundContextSummary(
|
|
465
|
+
`子会话${label ? '「' + label + '」' : ''} ${shortSessionId(sid)}… 回合${stage}` +
|
|
466
|
+
`${rawReason && rawReason !== stage ? '(reason=' + rawReason + ')' : ''}`,
|
|
467
|
+
);
|
|
468
|
+
return {
|
|
469
|
+
id: (globalThis.crypto?.randomUUID?.() ?? ('inj-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8))),
|
|
470
|
+
role: 'user',
|
|
471
|
+
content: [
|
|
472
|
+
// ★ 主人 2026-10-12 定:模拟用户消息的**首行直接给出源会话 id**(完整 id)。
|
|
473
|
+
{ type: 'text', text: '【来自会话 ' + sid + '】' },
|
|
474
|
+
{ type: 'text', text: summary },
|
|
475
|
+
{ type: 'text', text: '它的结尾正文:' },
|
|
476
|
+
{ type: 'text', text: String(input.text ?? '') },
|
|
477
|
+
{ type: 'text', text: '(这是子会话的自动回灌,父会话无需回复。)' },
|
|
478
|
+
],
|
|
479
|
+
// ★ 2026-10-12 主人定案:回灌**一律模拟用户消息**(source.kind='user'),
|
|
480
|
+
// 不再伪装成子会话结算通知 / 系统消息 / 开发者消息。
|
|
481
|
+
source: { kind: 'user' },
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ─────────────────────────── 转发到 QQ ───────────────────────────
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 往「父会话所在的 QQ」发一条消息。
|
|
489
|
+
*
|
|
490
|
+
* 链路(**全部复用插件已有的能力**,本库不自己重写降级/被动逻辑):
|
|
491
|
+
* · `sender.sendMarkdown(replyTarget, text)` —— replyTarget 自带最近 msgId ⇒ 被动回复;
|
|
492
|
+
* 插件的 sendMarkdown 自带 markdown → 纯文本的降级链。
|
|
493
|
+
* · 失败再退 `sender.sendText(replyTarget, text)`(纯文本兜底)。
|
|
494
|
+
*
|
|
495
|
+
* @returns 人话结果('' = 没配 sender,静默不发)
|
|
496
|
+
*/
|
|
497
|
+
async function forwardToQq(entry, text) {
|
|
498
|
+
const fwd = entry?.fwd;
|
|
499
|
+
const body = String(text ?? '').trim();
|
|
500
|
+
if (!fwd?.sender || !fwd?.replyTarget || !body) return '';
|
|
501
|
+
const target = fwd.replyTarget;
|
|
502
|
+
try {
|
|
503
|
+
if (typeof fwd.sender.sendMarkdown === 'function') {
|
|
504
|
+
await fwd.sender.sendMarkdown(target, body);
|
|
505
|
+
return 'md';
|
|
506
|
+
}
|
|
507
|
+
} catch { /* markdown 失败 → 退纯文本 */ }
|
|
508
|
+
try {
|
|
509
|
+
if (typeof fwd.sender.sendText === 'function') {
|
|
510
|
+
await fwd.sender.sendText(target, body);
|
|
511
|
+
return 'text';
|
|
512
|
+
}
|
|
513
|
+
} catch { /* ignore */ }
|
|
514
|
+
return '';
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 转发到 QQ 之前,把「只给插件自己看的标记」洗掉(主人 2026-10-12 要求:"剥掉 @")。
|
|
519
|
+
*
|
|
520
|
+
* 为什么必须洗:子会话继承的是同一个人设,所以它写正文时会带上本插件的出站约定标记。
|
|
521
|
+
* 而我们是用 `sender.sendMarkdown()` **直发**的,插件那层出站解析并不参与 ⇒ 这些标记会
|
|
522
|
+
* 原样变成裸文本出现在群里。实测踩到的两条:
|
|
523
|
+
* · `[rf:1012ad]` —— 引用标记,本来是给插件出站解析用的,转发后就是一行看不懂的裸文本;
|
|
524
|
+
* · `<@E9020753…>` —— **QQ 提及语法,会真的在群里 @ 到人**(子会话回主人一句就被 @ 一下)。
|
|
525
|
+
* 只洗这三类,别的(正文、换行、markdown)一律不动。
|
|
526
|
+
*/
|
|
527
|
+
export function sanitizeForQq(text) {
|
|
528
|
+
return String(text ?? '')
|
|
529
|
+
.replace(/\[\s*rf\s*:[^\]]*\]/gi, '') // [rf:1012ad] 引用标记
|
|
530
|
+
.replace(/<@[^>\s]{2,}>/g, '') // <@openid> 提及语法(会真 @ 到人)
|
|
531
|
+
.replace(/^\s*@[^\s@]{1,24}\s+/gm, '') // 行首零散的 @某人(只吃行首那一下)
|
|
532
|
+
.replace(/[ \t]{2,}/g, ' ') // 洗掉留下的多余空格
|
|
533
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
534
|
+
.trim();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 给转发消息套上行首归属(**路由键在最前**,会话名/预设/工作区跟在后面,引用回流就靠它认路) */
|
|
538
|
+
function withOwner(entry, text) {
|
|
539
|
+
return ownerLine({
|
|
540
|
+
shortId: entry?.shortId,
|
|
541
|
+
name: entry?.name,
|
|
542
|
+
preset: entry?.preset,
|
|
543
|
+
workspace: entry?.workspace,
|
|
544
|
+
}) + '\n' + sanitizeForQq(text);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─────────────────────────── 事件处理 ───────────────────────────
|
|
548
|
+
|
|
549
|
+
/** 该会话是不是某个盯梢对象的父会话(引用回流只从父会话的入站消息里认) */
|
|
550
|
+
function parentEntryOf(sid) {
|
|
551
|
+
for (const [, w] of watching) if (w.parentAgentId === sid) return w;
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 引用回流:入站消息里认出 `【子会话 xxxx】` → 把这段引用**插话**给对应子会话。
|
|
557
|
+
*
|
|
558
|
+
* 主人 2026-10-12 口径:**只靠行首的会话信息判断,不依赖台账**。
|
|
559
|
+
* 短 id → 完整 id 用 resolveShortId 当场查;查不到就静默放弃(绝不猜)。
|
|
560
|
+
*/
|
|
561
|
+
async function routeQuoteBack(root, inboundText) {
|
|
562
|
+
const short = parseOwner(inboundText);
|
|
563
|
+
if (!short) return;
|
|
564
|
+
const childId = resolveShortId(root, short);
|
|
565
|
+
if (!childId) return;
|
|
566
|
+
const agents = root.get('agents');
|
|
567
|
+
const child = agents?.get?.(childId) ?? null;
|
|
568
|
+
if (!child) return; // 子会话不在(不 live)→ 静默跳过
|
|
569
|
+
const payload =
|
|
570
|
+
'【群里有人引用了你发到 QQ 的那段输出】\n' +
|
|
571
|
+
String(inboundText ?? '').trim() +
|
|
572
|
+
'\n(这是群里的引用互动,按需回应即可。)';
|
|
573
|
+
const msg = userText(payload);
|
|
574
|
+
if (!isInjectableMessage(msg)) return;
|
|
575
|
+
try {
|
|
576
|
+
const busy = (() => {
|
|
577
|
+
try {
|
|
578
|
+
const st = String(child.status ?? child.phase ?? '');
|
|
579
|
+
return st !== '' && st !== 'idle';
|
|
580
|
+
} catch { return false; }
|
|
581
|
+
})();
|
|
582
|
+
if (busy) await child.inject(msg); // 忙 → 插话(不唤醒、不排队)
|
|
583
|
+
else await child.followup(msg); // 空闲 → 唤醒它来处理
|
|
584
|
+
const w = watching.get(childId);
|
|
585
|
+
if (w) w.quotedAt = Date.now();
|
|
586
|
+
} catch { /* 监听里绝不抛 */ }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* ★ 2026-10-13 补:**重启后把 QQ 转发能力补回来**。
|
|
591
|
+
*
|
|
592
|
+
* 背景:`fwd`(sender + replyTarget)**不可序列化**(sender 是 live 的 bot 对象),
|
|
593
|
+
* 所以启动恢复的名单里 `fwd` 只能是 null。现象就是"一个根因、两个症状":
|
|
594
|
+
* · 回灌照常进父会话,但 **不再转发到 QQ**;
|
|
595
|
+
* · 群里因此没有那条带「【子会话 xxxx】」行首的转发消息 ⇒ **引用回流也认不到路**(OWNER_RE 匹配不上)。
|
|
596
|
+
*
|
|
597
|
+
* 修法:**父会话一发言**(user/message 事件)就把 fwd 补回来 —— sender 取插件的 bot,
|
|
598
|
+
* replyTarget 从 session / root 上尽力凑。补上后当场可用;下次重启会再丢,但父会话一说话又补上,
|
|
599
|
+
* 因此只在"父会话长期静默"时转发会缺,实际影响很小。
|
|
600
|
+
* @returns {boolean} 是否可用
|
|
601
|
+
*/
|
|
602
|
+
function hydrateFwd(entry, root, session) {
|
|
603
|
+
try {
|
|
604
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
605
|
+
if (entry.fwd?.sender && entry.fwd?.replyTarget) return true;
|
|
606
|
+
// ★ 2026-10-13:**优先用"扩展工具侧注入"的 sender/replyTarget** —— 工具 env 里一定有这两样
|
|
607
|
+
// (`session_watch.mjs` 每次被调用都会把它们挂到 globalThis[FWD_KEY])。
|
|
608
|
+
// 实测 `root`(插件根 ctx)上拿不到 sender ⇒ 只靠 root 是补不回来的。
|
|
609
|
+
const injected = globalThis[FWD_KEY] ?? null;
|
|
610
|
+
const sender = injected?.sender ?? (root && (root.bot ?? root.sender)) ?? null;
|
|
611
|
+
const s = session ?? {};
|
|
612
|
+
const scope = String(s.scope ?? s.peerScope ?? '') || (s.peerId ? 'group' : '');
|
|
613
|
+
const peerId = String(s.peerId ?? s.peer ?? s.groupOpenid ?? '');
|
|
614
|
+
const replyTarget =
|
|
615
|
+
injected?.replyTarget ??
|
|
616
|
+
s.replyTarget ??
|
|
617
|
+
(root && (root.replyTarget ?? root.lastReplyTarget)) ??
|
|
618
|
+
(scope && peerId ? { scope, peerId } : null);
|
|
619
|
+
if (!sender || !replyTarget) {
|
|
620
|
+
try {
|
|
621
|
+
root?.logger?.warn?.(
|
|
622
|
+
`[ext-hook:session-watch] hydrateFwd 补不上:sender=${!!sender} replyTarget=${!!replyTarget}` +
|
|
623
|
+
` injected=${!!injected} rootKeys=[${root ? Object.keys(root).slice(0, 16).join(',') : '-'}]` +
|
|
624
|
+
` sessionKeys=[${Object.keys(s).slice(0, 16).join(',')}]`,
|
|
625
|
+
);
|
|
626
|
+
} catch { /* ignore */ }
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
entry.fwd = { sender, replyTarget };
|
|
630
|
+
try { root?.logger?.info?.('[ext-hook:session-watch] 已补回 QQ 转发能力(sender + replyTarget)'); } catch { /* ignore */ }
|
|
631
|
+
return true;
|
|
632
|
+
} catch { return false; }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function handler(root, session, event) {
|
|
636
|
+
try {
|
|
637
|
+
const sid = String(session?.id ?? session?.sessionId ?? '');
|
|
638
|
+
if (!sid) return;
|
|
639
|
+
const type = String(event?.type ?? '');
|
|
640
|
+
|
|
641
|
+
// ── 引用回流:只关心**入站 user 消息**,且只在"某个盯梢对象的父会话"里认 ──
|
|
642
|
+
if (type === 'user/message') {
|
|
643
|
+
const pe = parentEntryOf(sid);
|
|
644
|
+
if (pe) {
|
|
645
|
+
// ★ 2026-10-13:父会话一发言,就把重启后丢失的 QQ 转发能力补回来。
|
|
646
|
+
// 转发一断,群里就没有带「【子会话 xxxx】」行首的转发消息 ⇒ 引用回流也认不到路。
|
|
647
|
+
hydrateFwd(pe, root, session);
|
|
648
|
+
const t = textOf(event);
|
|
649
|
+
if (t && OWNER_RE.test(t)) await routeQuoteBack(root, t);
|
|
650
|
+
}
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const w = watching.get(sid);
|
|
655
|
+
if (!w) return;
|
|
656
|
+
|
|
657
|
+
// 累积(对应 qqbot 的 noteGenerated)
|
|
658
|
+
if (type === 'assistant/message') {
|
|
659
|
+
const t = textOf(event);
|
|
660
|
+
if (t) {
|
|
661
|
+
w.lastText = t;
|
|
662
|
+
// 二/三档要"所有正文":按块收,重复的丢弃
|
|
663
|
+
if (Array.isArray(w.texts)) { if (w.texts[w.texts.length - 1] !== t) w.texts.push(t); }
|
|
664
|
+
}
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// 报错也收(二/三档要一起转发)
|
|
668
|
+
if (/error/i.test(type)) {
|
|
669
|
+
const t = textOf(event) || String(event?.data?.message ?? event?.message ?? '').trim();
|
|
670
|
+
if (t && Array.isArray(w.errors)) w.errors.push(t);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (type !== 'turn/end') return;
|
|
674
|
+
|
|
675
|
+
// 结算:优先用累积到的那一段;没有再从会话实例兜底取
|
|
676
|
+
let text = w.lastText;
|
|
677
|
+
w.lastText = ''; // ★ 取走即清空:否则"只有工具调用、没有正文"的回合会重复回灌上一回合的正文
|
|
678
|
+
if (!text) text = lastAssistantText(root, sid);
|
|
679
|
+
w.seen = (w.seen ?? 0) + 1;
|
|
680
|
+
const rawReason = reasonKind(event?.data?.reason ?? event?.data?.status ?? event?.data?.stopReason);
|
|
681
|
+
w.lastReason = rawReason;
|
|
682
|
+
|
|
683
|
+
// 本回合收集物(二/三档用),取走即清
|
|
684
|
+
const allTexts = Array.isArray(w.texts) ? w.texts.slice() : [];
|
|
685
|
+
const errors = Array.isArray(w.errors) ? w.errors.slice() : [];
|
|
686
|
+
w.texts = [];
|
|
687
|
+
w.errors = [];
|
|
688
|
+
const mode = Number(w.mode) === 2 ? 2 : (Number(w.mode) === 3 ? 3 : 1);
|
|
689
|
+
|
|
690
|
+
// ── 转发到 QQ ──────────────────────────────────────────────
|
|
691
|
+
// 一档:只转末段;二/三档:所有正文 + 报错
|
|
692
|
+
const toForward = mode === 1
|
|
693
|
+
? (text ? [text] : [])
|
|
694
|
+
: [...allTexts, ...(errors.length ? ['⚠️ 报错:\n' + errors.join('\n')] : [])];
|
|
695
|
+
for (const piece of toForward) {
|
|
696
|
+
const body = withOwner(w, piece);
|
|
697
|
+
// 去重:同一条内容不重复转发
|
|
698
|
+
if (body === w.lastForwarded) continue;
|
|
699
|
+
const how = await forwardToQq(w, body);
|
|
700
|
+
if (how) w.lastForwarded = body;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── 回灌进父会话上下文(二档**不进**)──────────────────────
|
|
704
|
+
if (mode === 2) return;
|
|
705
|
+
if (!text) return; // 空回合(只有工具调用/无正文)→ 不回灌
|
|
706
|
+
// ★ 去重:兜底路径可能又取到"上一次已经灌过"的那段正文,内容相同就不再灌第二遍
|
|
707
|
+
if (text === w.lastSent) return;
|
|
708
|
+
w.lastSent = text;
|
|
709
|
+
|
|
710
|
+
const agents = root.get('agents');
|
|
711
|
+
const parent = agents?.get?.(w.parentAgentId) ?? null;
|
|
712
|
+
if (!parent) return; // 父不在(不 live)→ 静默跳过,不制造错误
|
|
713
|
+
|
|
714
|
+
const candidate = buildSettlementNotice({ sessionId: sid, label: w.label, rawReason, text });
|
|
715
|
+
if (!isInjectableMessage(candidate)) return; // 自检不过 → 宁可这次不回灌
|
|
716
|
+
try {
|
|
717
|
+
// ★ 2026-10-12 定案(照抄 qqbot 插件的入站模式 —— dist/features/qq-user-questions.js:646):
|
|
718
|
+
// 「忙时插话、空闲唤醒」
|
|
719
|
+
// ★ 三档(mode=3)主人要求"进上下文但**不唤醒**" ⇒ 无论忙闲都走 inject。
|
|
720
|
+
const busy = (() => {
|
|
721
|
+
try {
|
|
722
|
+
const st = String(parent.status ?? parent.phase ?? '');
|
|
723
|
+
return st !== '' && st !== 'idle';
|
|
724
|
+
} catch { return false; }
|
|
725
|
+
})();
|
|
726
|
+
if (mode === 3 || busy) await parent.inject(candidate); // 插话(不唤醒、不排队、不进输入框)
|
|
727
|
+
else await parent.followup(candidate); // 空闲 → 唤醒
|
|
728
|
+
w.firedAt = Date.now();
|
|
729
|
+
} catch { /* 注入失败 → 静默;监听里绝不抛,免得影响宿主事件链 */ }
|
|
730
|
+
} catch { /* 监听里绝不抛,免得影响宿主事件链 */ }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function installHook(root) {
|
|
734
|
+
if (HOOK_DISABLED) return;
|
|
735
|
+
|
|
736
|
+
// ① 把「当前实例的 handler」发布到 globalThis:已装好的那个监听会转发到最新实例,
|
|
737
|
+
// 所以改完 lib 热刷后新代码立刻接管,不必重挂监听。
|
|
738
|
+
globalThis[HANDLER_KEY] = (session, event) => { void handler(root, session, event); };
|
|
739
|
+
|
|
740
|
+
// ② 监听全局只装一次。lib 带 mtime 版本号加载,改一次就换一个模块实例;
|
|
741
|
+
// 若各实例各自 root.on 一遍,同一个 turn/end 会被回灌多次。
|
|
742
|
+
if (globalThis[HOOK_KEY]) { hooked = true; return; }
|
|
743
|
+
try {
|
|
744
|
+
// 与 qqbot 的 mc_tell 同款:挂在插件根 ctx 上,事件回调 (session, event)。
|
|
745
|
+
// 注意:回调里通过 globalThis 取 handler,永远调用最新那一版。
|
|
746
|
+
root.on('session/event', (session, event) => {
|
|
747
|
+
const h = globalThis[HANDLER_KEY];
|
|
748
|
+
if (typeof h === 'function') h(session, event);
|
|
749
|
+
});
|
|
750
|
+
globalThis[HOOK_KEY] = true;
|
|
751
|
+
hooked = true;
|
|
752
|
+
} catch { hooked = false; }
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 登记一个"要盯着的子会话"。
|
|
757
|
+
*
|
|
758
|
+
* @param {object} opts
|
|
759
|
+
* · parentAgentId 父会话 id(必填)
|
|
760
|
+
* · childSessionId 子会话 id(必填)
|
|
761
|
+
* · label 标签(可选)
|
|
762
|
+
* · name 会话名(可选;归属行显示用,缺省取 label)
|
|
763
|
+
* · preset 预设名(可选;归属行显示用)
|
|
764
|
+
* · workspace 工作区(可选;归属行显示用,建议传目录名而非全路径)
|
|
765
|
+
* · mode 1=只回灌末段+转末段(默认) 2=只转所有正文+报错(不进父上下文) 3=进父上下文(不唤醒)+全转
|
|
766
|
+
* · sender 父会话的 sender(可选;给了才转发 QQ)
|
|
767
|
+
* · replyTarget 父会话所在的 QQ 目标(可选;自带最近 msgId ⇒ 被动优先)
|
|
768
|
+
* · peerName 父会话所在群/私聊的名字(可选,只用于显示)
|
|
769
|
+
* @returns 人话描述(挂成功/失败原因),不抛错。
|
|
770
|
+
*/
|
|
771
|
+
export async function register(root, opts = {}) {
|
|
772
|
+
const parentAgentId = String(opts.parentAgentId ?? '').trim();
|
|
773
|
+
const childSessionId = String(opts.childSessionId ?? '').trim();
|
|
774
|
+
const label = String(opts.label ?? '').trim();
|
|
775
|
+
const mode = Number(opts.mode) === 2 ? 2 : (Number(opts.mode) === 3 ? 3 : 1);
|
|
776
|
+
if (!parentAgentId || !childSessionId) return '未挂(缺 parentAgentId 或 childSessionId)';
|
|
777
|
+
if (!root || typeof root.on !== 'function') return '未挂(root 不支持 .on)';
|
|
778
|
+
installHook(root);
|
|
779
|
+
if (HOOK_DISABLED) return '未挂(回灌钩子当前被 HOOK_DISABLED 主动关闭,属预期;恢复见 installHook 注释)';
|
|
780
|
+
if (!hooked) return '未挂(root.on 抛错)';
|
|
781
|
+
const fwd = (opts.sender && opts.replyTarget) ? { sender: opts.sender, replyTarget: opts.replyTarget } : null;
|
|
782
|
+
watching.set(childSessionId, {
|
|
783
|
+
parentAgentId,
|
|
784
|
+
label,
|
|
785
|
+
mode,
|
|
786
|
+
shortId: shortSessionId(childSessionId),
|
|
787
|
+
/** 归属行用的三件套(主人 2026-10-12 定:会话名 / 预设名 / 工作区) */
|
|
788
|
+
name: String(opts.name ?? opts.label ?? '').trim(),
|
|
789
|
+
preset: String(opts.preset ?? '').trim(),
|
|
790
|
+
workspace: String(opts.workspace ?? '').trim(),
|
|
791
|
+
peerName: String(opts.peerName ?? ''),
|
|
792
|
+
fwd,
|
|
793
|
+
lastText: '',
|
|
794
|
+
texts: [],
|
|
795
|
+
errors: [],
|
|
796
|
+
seen: 0,
|
|
797
|
+
firedAt: 0,
|
|
798
|
+
lastForwarded: '',
|
|
799
|
+
});
|
|
800
|
+
await persist();
|
|
801
|
+
const modeText = mode === 2 ? '二档(全部正文+报错→QQ,不进父上下文)'
|
|
802
|
+
: mode === 3 ? '三档(进父上下文但不唤醒 + 全转QQ)'
|
|
803
|
+
: '一档(末段回灌父会话 + 同转QQ)';
|
|
804
|
+
return `已挂(子=${shortSessionId(childSessionId)}… → 父=${shortSessionId(parentAgentId)}…;${modeText}${fwd ? ';QQ 转发就绪' : ';⚠️ 没拿到 sender/replyTarget,QQ 转发不可用'})`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** 改档位(已挂的盯梢可随时改) */
|
|
808
|
+
export async function setMode(childSessionId, mode) {
|
|
809
|
+
const w = watching.get(String(childSessionId ?? ''));
|
|
810
|
+
if (!w) return '没找到这个盯梢(先用 session_watch list 看)';
|
|
811
|
+
w.mode = Number(mode) === 2 ? 2 : (Number(mode) === 3 ? 3 : 1);
|
|
812
|
+
await persist();
|
|
813
|
+
return `已改为 ${w.mode} 档`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** 取消盯梢 */
|
|
817
|
+
export async function unregister(childSessionId) {
|
|
818
|
+
const ok = watching.delete(String(childSessionId ?? ''));
|
|
819
|
+
if (ok) await persist();
|
|
820
|
+
return ok ? '已取消盯梢' : '没找到这个盯梢';
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** 给别的工具(如 session_watch 状态查询)读当前盯着的清单 */
|
|
824
|
+
export function listWatching() {
|
|
825
|
+
ensureLoaded(); // ★ 先保证存档已读回内存(重启后第一次调它就能看到完整名单)
|
|
826
|
+
return [...watching.entries()].map(([sid, w]) => ({
|
|
827
|
+
childSessionId: sid,
|
|
828
|
+
shortId: shortSessionId(sid),
|
|
829
|
+
parentAgentId: w.parentAgentId,
|
|
830
|
+
parentShortId: shortSessionId(w.parentAgentId),
|
|
831
|
+
label: w.label,
|
|
832
|
+
mode: w.mode ?? 1,
|
|
833
|
+
/** 归属行三件套(会随转发消息发到 QQ) */
|
|
834
|
+
name: w.name ?? '',
|
|
835
|
+
preset: w.preset ?? '',
|
|
836
|
+
workspace: w.workspace ?? '',
|
|
837
|
+
peerName: w.peerName ?? '',
|
|
838
|
+
qqReady: !!w.fwd,
|
|
839
|
+
hasText: !!w.lastText,
|
|
840
|
+
turnsSeen: w.seen ?? 0,
|
|
841
|
+
lastFiredAt: w.firedAt ?? 0,
|
|
842
|
+
quotedAt: w.quotedAt ?? 0,
|
|
843
|
+
}));
|
|
844
|
+
}
|