@zaofan/dsh-qqbot 1.10.1 → 1.10.3

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.
@@ -152,6 +152,51 @@ npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
152
152
  - **配套工具**:`session_create`(建会话)、`session_send`(往会话投递)、`session_watch`(看/改档/取消盯梢)、
153
153
  `sessions_list`(列工作区)、`session_children`(看会话血缘)。
154
154
 
155
+ **引用回流 · 在群里「引用」它的输出,就能接着跟它聊**
156
+
157
+ 「回灌」是**子会话 → 父会话**的单向通报;**引用回流**是**反方向** —— 你在群里**引用子会话发的那条输出**,
158
+ 这条引用会被**路由回那个子会话**,让它接着答。
159
+
160
+ - **怎么用**:子会话跑完一轮后,群里会出现一条**行首带 `【子会话 xxxx】` 的转发消息**;
161
+ 你**引用那条消息**(QQ 的引用功能)再说一句,这句话就送进了那个子会话。
162
+ - **它收到什么**:`【群里有人引用了你发到 QQ 的那段输出】` + 你的原话 + `(这是群里的引用互动,按需回应即可。)`
163
+ - **忙 / 闲两种处理**:子会话**正在跑** → 当**插话**送进去(不另起一轮);**空闲** → **唤醒**它来处理这条引用。
164
+ - **认路方式**:只看引用文本里**行首的归属短 id**(`【子会话 fc871e99】` 里的 `fc871e99`),
165
+ **当场解析**成完整会话 id、**不依赖任何台账**;解析不到就**静默放弃**(绝不猜是哪个会话)。
166
+ - **⚠️ 前提**:那个子会话**必须是活着的**(宿主里有它的 agent)。没活着就**静默跳过** ——
167
+ 所以宿主重启后,**先让它动一下**(`session_send` 投一句、或它在被盯梢期间再跑一轮)才会恢复响应。
168
+ - **怎么看有没有生效**:`session_watch action=list` 里每条盯梢末尾的 **`最近被引用=月-日 时:分`**,
169
+ 就是最近一次引用回流的落点时间;从没被引用过则不显示。
170
+
171
+ **会话列表 · `/会话列表` 命令**(v1.10.3+): 三步向导选一个会话,**绑定它**并给你一行**可复制的路由模板**。
172
+ 不需要是子会话 —— 选一个**任意已有会话**(哪怕是很早以前建、早就休眠的)都能绑。
173
+
174
+ - **三步**(每步一页 20 个,点字母选;可翻页 / 取消):
175
+ 1. **工作区** —— 显示**真实文件夹路径**(如 `D:\newwenjianjia\aiwork\鲸鱼娘`)+ 该区会话数,多的在前;
176
+ 2. **该区会话** —— 每行 `🟢/⚪ 标题 · 短id · 创建时间`。**🟢 = 宿主里正在跑,⚪ = 已休眠**(都能绑,休眠的会被唤醒)。
177
+ ⚠️ **只有正在跑的会话才拿得到标题** —— 宿主**没给已结束会话存标题**,那些只能靠短 id + 时间认;
178
+ 3. **回灌档位** —— `一档 / 二档 / 三档`,含义同 `/子会话`。
179
+ - **选完吐出模板**:
180
+ ```
181
+ [s:xxxxxxxx] 在这里写你要发给这个会话的内容
182
+ ```
183
+ **复制这行、改掉后半句**,直接发到群里,这句话就被投进那个会话 ——
184
+ 于是你也能**先引用一张图 / 一段别的内容,再补上这行标记**,把图片一起路由过去。
185
+ - **别名**:`/sessionlist`、`/sessions`。
186
+ - **怎么取数的**:直接问宿主服务(`sessionPersistence.list()` 给全部会话 + 真实 `cwd`;
187
+ `sessions.list()` 给 live 会话的标题)—— **不解压任何 `.zstd`**,所以一秒出结果、也不会卡。
188
+
189
+ **通用路由 · 正文里带 `[s:会话id]` 就投给那个会话**(v1.10.3+)
190
+
191
+ - **怎么写**:群里任何一条消息,只要**正文**里有 **`[s:8位短id]`**(或 `【会话 完整id】`),
192
+ 这条消息就会被**投递进那个会话**,标记本身会被自动剥掉、不会出现在对方看到的内容里。
193
+ - **和「引用回流」什么关系**:**回流**是"回"—— 必须**引用子会话发的那条转发消息**;
194
+ **路由**是"去"—— **任意会话、不用先绑过、不用是子会话**,只要正文里有那个 id。
195
+ - **能带东西**:你的正文 + **这条消息自带的附件**(图片 / 文件会一并进去)——
196
+ 所以"引用一张图 + 一行 `[s:xxxx]`"就能把图路由给对方。
197
+ - **忙 / 闲两种处理**:目标会话**正在跑** → 当**插话**送进去;**空闲** → **唤醒**它。
198
+ - **⚠️ 前提**:目标会话**必须是活着的**(宿主里有它的 agent)。没活着就**静默跳过**,绝不猜、也绝不新建。
199
+
155
200
  ### 🔨 锤子面板: 禁言 / 踢人 (v1.6.2+,2026-10-04 补充)
156
201
 
157
202
  dock 里的「🔨 锤子」(原「🔇 禁言」)下面分两页:**🔇 禁言**(走官方接口,机器人为管理员即可,无需任何登录)和 **👢 踢人**(**官方至今未开放踢人接口**,改走**你自己的 QQ 号** + 群官网 `qun.qq.com`)。
@@ -0,0 +1,603 @@
1
+ /**
2
+ * 会话列表.mjs — `/会话列表` 斜杠命令(扩展命令第五套)
3
+ *
4
+ * ★ 2026-10-14 主人需求(原话):
5
+ * 「再做个 /会话列表 的斜杠命令:先列出工作区选项 → 选了以后再列出会话列表选项(一页不要太多)
6
+ * → 再列出回灌模式。选择会话后可以**绑定任何已有会话**,不一定要建立的子会话,能钩子勾住并回灌吗?
7
+ * 选择成功后,往 qq 发送一条消息模板,该模板的开头包含了会话 id,只要用户复制这条模板、
8
+ * 修改发送就可以将这段话路由到对应会话 —— 这样就可以引用别的内容发送给路由的会话了(比如图片)」
9
+ *
10
+ * 所以本命令干三件事:
11
+ * ① 三步向导:**工作区 → 该工作区的会话 → 回灌模式**;
12
+ * ② 选完**绑上回灌钩子**(`register()` 的 childSessionId 本就认任意会话,不限"建出来的子会话");
13
+ * ③ 往 QQ 发一条**可复制的模板**,开头带会话 id ⇒ 复制改内容即可路由到那个会话。
14
+ *
15
+ * 路由靠 `tools/lib/session_watch_lib.mjs` 的**通用路由**(正文里出现 `[s:短id]` 或 `【会话 id】` 就投递)。
16
+ *
17
+ * ⚠️ 从 `/子会话` 抄来的四条硬经验(都踩过):
18
+ * 1. `paint()` 里**必须**做三件事:登记插件卡表、**命令侧自写注册表**、`W.set(cardId, st)`。
19
+ * (paint 每次换新 cardId,只在 handler/翻页处登记会漏 ⇒ 选完一屏就报"向导已过期")
20
+ * 2. 按钮 id **绝不能带冒号**:插件把 button_data 拆成 `ext:<工具名>:<卡id>:<按钮id>`,
21
+ * 要求**恰好 3 段**,带冒号会 split 出 4 段被静默丢弃(现象:点了只弹"操作成功")。
22
+ * 3. 命令侧 `ctx.caps` 有值、**按钮回调的 ctx 是把能力平铺在自身**(没有 caps 键)
23
+ * ⇒ 一律写 `ctx?.caps ?? ctx`。
24
+ * 4. `keyboardOf` / `regCardSelf` 里的**工具名必须与本文件一致**,否则插件查表查不到。
25
+ */
26
+ const PAGE = 20; // 一页 20 个选项(4 行 × 5 列;第 5 行留给导航)
27
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRST';
28
+ const CARD_EXPIRE_MS = 10 * 60_000;
29
+ const MAX_PAGE_MS = 30 * 60_000;
30
+ const TOOL = '会话列表'; // ★ 必须与文件名/注册名一致
31
+
32
+ const STEPS = ['workspace', 'session', 'mode'];
33
+ const STEP_CN = {
34
+ workspace: '工作区',
35
+ session: '会话',
36
+ mode: '回灌模式',
37
+ };
38
+
39
+ /** 向导状态表(cardId → state) */
40
+ const W = new Map();
41
+ let seq = 0;
42
+
43
+ /** 字符串兜底 */
44
+ function cn(v) { return v === undefined || v === null ? '' : String(v); }
45
+ function short(s, n) { const t = cn(s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; }
46
+
47
+ /** 诊断日志(沿用 /子会话 的做法:写死路径,排查"点了没反应"最有用) */
48
+ const DIAG_FILE = 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot/.qqbot-extensions/data/会话列表-diag.log';
49
+ async function diag(msg) {
50
+ try {
51
+ const { appendFile, mkdir } = await import('node:fs/promises');
52
+ const { dirname } = await import('node:path');
53
+ await mkdir(dirname(DIAG_FILE), { recursive: true });
54
+ await appendFile(DIAG_FILE, `${new Date().toISOString()} ${msg}\n`);
55
+ } catch { /* 诊断失败绝不影响主流程 */ }
56
+ }
57
+
58
+ /** 扩展数据根(与 /子会话 同口径) */
59
+ function dataRootOf(ctx) {
60
+ try {
61
+ const caps = ctx?.caps ?? ctx ?? {};
62
+ const p = caps?.paths ?? {};
63
+ const cand = p.dataRoot ?? caps.dataRoot ?? ctx?.dataRoot ?? '';
64
+ if (cand) return String(cand);
65
+ } catch { /* ignore */ }
66
+ return 'D:/newwenjianjia/aiwork/鲸鱼娘/dshqqbot';
67
+ }
68
+
69
+ /** 命令侧自写卡表(不依赖插件是否注入 registerInteractionCard) */
70
+ async function regCardSelf(ctx, cardId, buttonIds) {
71
+ const root = dataRootOf(ctx);
72
+ if (!root) return false;
73
+ try {
74
+ const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
75
+ const { join, dirname } = await import('node:path');
76
+ const file = join(root, '.qqbot', 'ext-tool-cards.json');
77
+ let table = { version: 1, cards: [] };
78
+ try { table = JSON.parse(readFileSync(file, 'utf8')) ?? table; } catch { /* 首次或坏文件 */ }
79
+ if (!Array.isArray(table.cards)) table.cards = [];
80
+ const mc = ctx?.message ?? {};
81
+ const rt = ctx?.replyTarget ?? {};
82
+ const pe = ctx?.peer ?? {};
83
+ const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
84
+ const targetId = cn(mc.groupOpenid || rt.targetId || pe.groupId || pe.groupOpenid || pe.peerId || pe.id || mc.senderId);
85
+ const now = Date.now();
86
+ table.cards = table.cards.filter((c) => Number(c?.expireAt ?? 0) > now && !(c?.toolName === TOOL && c?.cardId === cardId));
87
+ table.cards.push({ toolName: TOOL, cardId, buttonIds, scope, targetId, createdAt: now, expireAt: now + CARD_EXPIRE_MS });
88
+ if (table.cards.length > 200) table.cards = table.cards.slice(-200);
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ writeFileSync(file, JSON.stringify(table, null, 2));
91
+ return true;
92
+ } catch { return false; }
93
+ }
94
+
95
+ /** 发卡(markdown + 按钮键盘);四级兜底,与 /子会话 同款 */
96
+ async function sendCard(ctx, text, keyboard) {
97
+ const caps = ctx?.caps ?? ctx ?? {};
98
+ const mc = ctx?.message ?? {};
99
+ const rt = ctx?.replyTarget ?? {};
100
+ const pe = ctx?.peer ?? {};
101
+ const scope = cn(rt.scope || ctx?.scope || pe.scope || mc.kind || ((mc.groupOpenid || pe.groupId || pe.peerId) ? 'group' : 'c2c'));
102
+ const gid = cn(mc.groupOpenid || rt.targetId || rt.openid || rt.id || pe.groupId || pe.groupOpenid || (scope === 'group' ? (pe.peerId || pe.id) : '') || '');
103
+ const uid = cn(mc.senderId || rt.openid || rt.id || pe.openid || pe.userId || '');
104
+ const api = caps.api;
105
+ if (keyboard && typeof api === 'function' && (gid || uid)) {
106
+ const path = gid ? `/v2/groups/${gid}/messages` : `/v2/users/${uid}/messages`;
107
+ const body = { msg_type: 2, markdown: { content: text }, keyboard, ...(gid ? {} : { content: text }) };
108
+ try {
109
+ const r = await api(path, { method: 'POST', body });
110
+ void diag(`[sendCard] ①api=${JSON.stringify(r ?? null).slice(0, 140)}`);
111
+ if (r && r.ok !== false) return true;
112
+ } catch (e) { void diag(`[sendCard] ①抛错=${short(e?.message ?? e, 140)}`); }
113
+ }
114
+ try {
115
+ if (keyboard && typeof caps.markdownCard === 'function') {
116
+ const r2 = await caps.markdownCard(text, keyboard);
117
+ if (r2) return true;
118
+ }
119
+ } catch { /* ignore */ }
120
+ try {
121
+ if (keyboard && typeof ctx?.sender?.sendMarkdownWithKeyboard === 'function') {
122
+ await ctx.sender.sendMarkdownWithKeyboard(ctx.replyTarget, text, keyboard);
123
+ return true;
124
+ }
125
+ } catch { /* ignore */ }
126
+ try { if (typeof caps.markdown === 'function' && (await caps.markdown(text))) return true; } catch { /* ignore */ }
127
+ return false;
128
+ }
129
+
130
+ /** 按钮(id 不能带冒号) */
131
+ function btn(id, label, style = 1) {
132
+ return {
133
+ id,
134
+ render_data: { label: short(label, 18), visited_label: short(label, 18), style },
135
+ action: { type: 1, permission: { type: 2 }, data: id, unsupport_tips: '请升级QQ客户端后重试' },
136
+ };
137
+ }
138
+
139
+ /** 拼 QQ inline_keyboard;★ 工具名必须是本命令 */
140
+ function keyboardOf(rows, cardId) {
141
+ return {
142
+ content: {
143
+ rows: rows.map((r) => ({ buttons: r.map((b) => ({ ...b, action: { ...b.action, data: `ext:${TOOL}:${cardId}:${b.id}` } })) })),
144
+ },
145
+ };
146
+ }
147
+
148
+ /** 加载回灌库(按 mtime 版本化,改完 lib 热刷即生效) */
149
+ async function loadLib() {
150
+ const url = new URL(`../tools/lib/session_watch_lib.mjs?v=${Date.now()}`, import.meta.url);
151
+ return await import(url.href);
152
+ }
153
+
154
+ /**
155
+ * 把工作区转义目录名**还原成真实路径**(主人 2026-10-14 要求「显示真实文件夹名字」)。
156
+ *
157
+ * 宿主规则(实测推导,见 `~/.dsh/sessions/`):
158
+ * · 整体被 `--` 包起来,首尾各去掉一层;
159
+ * · **`-` = 路径分隔符**(`\`);
160
+ * · **`~XXXX` = 一个 UTF-16 码元**(中文/特殊字符都这么转义);
161
+ * · 开头的**单字母 + `-`** 是盘符(如 `D-` ⇒ `D:`)。
162
+ * 例:`--D-newwenjianjia-aiwork-~9CB8~9C7C~5A18--` ⇒ `D:\newwenjianjia\aiwork\鲸鱼娘`
163
+ *
164
+ * ⚠️ 已知不可逆点:原路径里若本来就有 `-`,会被同样当成分隔符(宿主转义时没有区分手段)。
165
+ */
166
+ function wsPath(dir) {
167
+ const t = cn(dir).replace(/^--/, '').replace(/--$/, '');
168
+ const parts = [];
169
+ let cur = '';
170
+ let i = 0;
171
+ while (i < t.length) {
172
+ const ch = t[i];
173
+ if (ch === '-') { parts.push(cur); cur = ''; i += 1; continue; }
174
+ const m = /^~([0-9A-Fa-f]{4})/.exec(t.slice(i, i + 5));
175
+ if (m) { cur += String.fromCharCode(parseInt(m[1], 16)); i += 5; continue; }
176
+ cur += ch;
177
+ i += 1;
178
+ }
179
+ parts.push(cur);
180
+ const segs = parts.filter((p) => p !== '');
181
+ if (segs.length && /^[A-Za-z]$/.test(segs[0])) segs[0] = `${segs[0]}:`;
182
+ return segs.join('\\');
183
+ }
184
+
185
+ /** 卡片上显示用:太长时保留**尾部**(尾部才是能分辨的那几级) */
186
+ function wsDisplay(dir, max = 52) {
187
+ const full = wsPath(dir);
188
+ if (full.length <= max) return full;
189
+ return '…' + full.slice(-(max - 1));
190
+ }
191
+
192
+ // ── 候选值:**优先问宿主服务**(工作区=会话的 cwd,会话=sessionPersistence.list(),
193
+ // 标题=sessionTitle.get()),拿不到才回退扫盘。主人 2026-10-14 定:
194
+ // 「一定要解析会话吗?不能从 web 界面里拿吗?就像会话排序一样」。──────
195
+
196
+ const OPT_CACHE = new Map();
197
+ const OPT_TTL_MS = 60 * 1000; // 候选缓存:60 秒(新会话能及时冒出来)
198
+ const TTL_CACHE = new Map(); // 标题缓存(带时间戳,5 分钟过期)
199
+ const TITLE_TTL_MS = 5 * 60 * 1000;
200
+ let KERNEL = null;
201
+ const CTX_CACHE = { sender: null, replyTarget: null, agentId: '', sessionId: '' };
202
+
203
+ /** 取宿主服务容器(cordis ctx):handler 里缓存过就直接用,否则依次看 caps.kernel / kernel */
204
+ function kernelOf(ctx) {
205
+ if (KERNEL) return KERNEL;
206
+ const c = ctx ?? {};
207
+ for (const v of [c?.caps?.kernel, c?.kernel]) if (v) return v;
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * 从容器里取服务:`kernel.get(name)` 是主路。
213
+ *
214
+ * ⚠️ 踩过的坑(2026-10-14 实测报错 `cannot get property "ctx" without inject`):
215
+ * cordis Context 上的 `ctx` / `caps` 等属性是 **getter**,未 inject 时**读取即抛**。
216
+ * 所以**绝不能**把它们放进数组字面量里"顺手取一下" —— 必须逐个包 try。
217
+ */
218
+ function svcOf(ctx, name) {
219
+ const k = kernelOf(ctx);
220
+ if (!k) return null;
221
+ try {
222
+ if (typeof k.get === 'function') {
223
+ const v = k.get(name);
224
+ if (v) return v;
225
+ }
226
+ } catch { /* ignore */ }
227
+ for (const key of ['root', 'ctx', 'caps']) {
228
+ try {
229
+ const c = k[key];
230
+ if (!c) continue;
231
+ const v = typeof c.get === 'function' ? c.get(name) : c[name];
232
+ if (v) return v;
233
+ } catch { /* ignore */ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /** 会话 id 归一:宿主持久化里带 `session-` 前缀,实时 agent id 不带 —— 两种都留一手 */
239
+ function bareId(id) { const s = cn(id); return s.startsWith('session-') ? s.slice('session-'.length) : s; }
240
+ function fullId(id) { const s = cn(id); return s.startsWith('session-') ? s : `session-${s}`; }
241
+
242
+ /**
243
+ * 宿主里**全部**会话(含已结束的)。返回 [{id, cwd, createdAt, sizeBytes}];
244
+ * 拿不到服务时返回 null,调用方回退扫盘。
245
+ */
246
+ async function listAllSessions(ctx) {
247
+ const sp = svcOf(ctx, 'sessionPersistence');
248
+ if (!sp || typeof sp.list !== 'function') {
249
+ void diag(`[svc] 拿不到 sessionPersistence(kernel=${!!kernelOf(ctx)} sp=${!!sp})`);
250
+ return null;
251
+ }
252
+ try {
253
+ const r = await sp.list();
254
+ const arr = Array.isArray(r) ? r : (r && typeof r === 'object' ? Object.values(r) : []);
255
+ const out = arr.map((it) => {
256
+ const h = it?.header ?? {};
257
+ return {
258
+ id: cn(h.id ?? it?.sessionId ?? it?.id),
259
+ cwd: cn(h.cwd ?? it?.cwd),
260
+ createdAt: Number(h.createdAt ?? it?.createdAt ?? 0) || 0,
261
+ sizeBytes: Number(it?.sizeBytes ?? 0) || 0,
262
+ };
263
+ }).filter((x) => x.id);
264
+ if (out.length === 0) return null;
265
+ return out;
266
+ } catch (e) {
267
+ void diag(`[svc] sessionPersistence.list 抛错=${short(e?.message ?? e, 160)}`);
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * live(正在跑)会话表:bare id → {title, cwd}。
274
+ *
275
+ * ★ 标题的唯一可靠来源(2026-10-14 实测定案):
276
+ * `sessions.list()` 每一项的 `log`(内存事件数组)里,**最后一个 `session/title` 事件的
277
+ * `data.title`**。实测 14 个 live 会话全部取到标题("做早饭会话问候""早饭群"…)。
278
+ *
279
+ * ⚠️ 两条死路(别再试):
280
+ * ① `sessionTitle.get(id)` 在本版宿主**是坏的** —— 抛 `session.snapshotEvents is not a function`;
281
+ * ② `sessionPersistence.list()` 的 `header` 里**根本没有 title 字段**
282
+ * (只有 version/id/createdAt/cwd/isSeeded/delegationDepth/agentPreset)。
283
+ * ⇒ 已结束(非 live)的会话**拿不到标题**,只能显示短 id + 创建时间。
284
+ */
285
+ async function liveMap(ctx) {
286
+ const hit = TTL_CACHE.get('live');
287
+ if (hit && Date.now() - hit.at < 15 * 1000) return hit.v;
288
+ const out = new Map();
289
+ const ss = svcOf(ctx, 'sessions');
290
+ if (ss && typeof ss.list === 'function') {
291
+ try {
292
+ const r = await ss.list();
293
+ const arr = Array.isArray(r) ? r : (r && typeof r === 'object' ? Object.values(r) : []);
294
+ for (const o of arr) {
295
+ const id = bareId(o?.id ?? o?.sessionId ?? '');
296
+ if (!id) continue;
297
+ let title = '';
298
+ const lg = o?.log;
299
+ if (Array.isArray(lg)) {
300
+ for (let i = lg.length - 1; i >= 0; i--) {
301
+ if (String(lg[i]?.type ?? '') === 'session/title') {
302
+ const t = lg[i]?.data?.title;
303
+ if (typeof t === 'string' && t) { title = t; break; }
304
+ }
305
+ }
306
+ }
307
+ out.set(id, { title, cwd: cn(o?.header?.cwd ?? '') });
308
+ }
309
+ } catch (e) { void diag(`[svc] sessions.list 抛错=${short(e?.message ?? e, 160)}`); }
310
+ }
311
+ TTL_CACHE.set('live', { at: Date.now(), v: out });
312
+ return out;
313
+ }
314
+
315
+ /** 会话数 / 工作区数 摘要(卡片顶部显示,让人一眼知道规模) */
316
+ let LAST_SCOPE = '';
317
+
318
+ async function optionsOf(ctx, step, st) {
319
+ const key = step === 'session' ? `session::${st.picked.workspace?.value ?? ''}` : step;
320
+ const hit = OPT_CACHE.get(key);
321
+ if (hit && Date.now() - hit.at < OPT_TTL_MS) return hit.v;
322
+ const out = await optionsOfRaw(ctx, step, st);
323
+ if (Array.isArray(out) && out.length > 0) OPT_CACHE.set(key, { at: Date.now(), v: out });
324
+ return out;
325
+ }
326
+
327
+ async function optionsOfRaw(ctx, step, st) {
328
+ try {
329
+ const lib = await loadLib();
330
+ if (step === 'workspace') {
331
+ const all = await listAllSessions(ctx);
332
+ if (all) {
333
+ const cnt = new Map();
334
+ for (const s of all) if (s.cwd) cnt.set(s.cwd, (cnt.get(s.cwd) ?? 0) + 1);
335
+ const list = [...cnt.entries()].sort((a, b) => b[1] - a[1]);
336
+ LAST_SCOPE = `宿主 ${all.length} 个会话 / ${list.length} 个工作区`;
337
+ void diag(`[opts] workspace 走宿主:${LAST_SCOPE}`);
338
+ return list.map(([cwd, n]) => ({ value: cwd, label: `${wsDisplay(cwd)} · ${n} 个` }));
339
+ }
340
+ const dirs = lib.listWorkspaceDirs();
341
+ LAST_SCOPE = `扫盘 ${dirs.length} 个工作区(宿主服务不可用)`;
342
+ void diag(`[opts] workspace 回退扫盘:${dirs.length} 个`);
343
+ return dirs.map((d) => ({ value: d, label: wsDisplay(wsPath(d)) }));
344
+ }
345
+ if (step === 'session') {
346
+ const ws = st.picked.workspace?.value ?? '';
347
+ const live = await liveMap(ctx);
348
+ const all = await listAllSessions(ctx);
349
+ if (all) {
350
+ const seen = new Set();
351
+ const mine = all.filter((s) => {
352
+ const b = bareId(s.id);
353
+ if (seen.has(b)) return false;
354
+ seen.add(b);
355
+ return !ws || s.cwd === ws;
356
+ });
357
+ // 排序:**能叫出名字的(live 有标题)排最前** → 再按创建时间新在前。
358
+ // 理由:已结束的会话宿主里没有标题(见 liveMap 注释),只能靠时间认。
359
+ const score = (s) => (live.get(bareId(s.id))?.title ? 1 : 0);
360
+ mine.sort((a, b) => (score(b) - score(a)) || (b.createdAt - a.createdAt));
361
+ const shown = mine.slice(0, 80);
362
+ const named = mine.filter((s) => live.get(bareId(s.id))?.title).length;
363
+ LAST_SCOPE = `${mine.length} 个会话(${named} 个在线有名)`;
364
+ void diag(`[opts] session 走宿主 ws=${ws} 命中 ${mine.length},有名 ${named},取前 ${shown.length}`);
365
+ return shown.map((s) => {
366
+ const info = live.get(bareId(s.id));
367
+ const t = info?.title ?? '';
368
+ const when = s.createdAt
369
+ ? new Date(s.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
370
+ : '—';
371
+ const tag = info ? '🟢' : '⚪';
372
+ const id8 = bareId(s.id).slice(0, 8);
373
+ const head = t ? short(t, 24) : id8;
374
+ return { value: s.id, label: `${tag} ${head}${t ? ` · ${id8}` : ''} · ${when}` };
375
+ });
376
+ }
377
+ const dirs = lib.listSessionDirs(ws || undefined);
378
+ void diag(`[opts] session 回退扫盘 ws=${ws} 得到 ${dirs.length} 个`);
379
+ return dirs.map((s) => {
380
+ const when = s.mtime ? new Date(s.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—';
381
+ const isSub = s.id.startsWith('session-') ? '子会话 ' : '';
382
+ return { value: s.id, label: `${s.short} ${isSub}${when}` };
383
+ });
384
+ }
385
+ if (step === 'mode') {
386
+ return [
387
+ { value: 1, label: '一档 · 末段回灌 + 转发到 QQ(默认)' },
388
+ { value: 2, label: '二档 · 全部正文 + 报错 只转 QQ(不进父上下文)' },
389
+ { value: 3, label: '三档 · 进父上下文但不唤醒 + 转 QQ' },
390
+ ];
391
+ }
392
+ } catch (e) { void diag(`[opts] ${step} 抛错=${short(e?.message ?? e, 160)}`); }
393
+ return [];
394
+ }
395
+
396
+ // ── 卡片渲染 ────────────────────────────────────────────────────────────
397
+
398
+ function cardText(st) {
399
+ const L = [];
400
+ L.push(`### 🗂 会话列表 · 第 ${st.stepIdx + 1}/${STEPS.length} 步:${STEP_CN[st.step]}`);
401
+ L.push('');
402
+ for (const k of STEPS) {
403
+ const v = st.picked[k];
404
+ if (k === 'session') {
405
+ L.push(`- ${STEP_CN[k]}:${v ? `${short(v.label, 40)}(${short(v.value, 20)})` : '(待选)'}`);
406
+ } else {
407
+ L.push(`- ${STEP_CN[k]}:${v ? v.label : '(待选)'}`);
408
+ }
409
+ }
410
+ L.push('');
411
+ if (LAST_SCOPE && st.step !== 'mode') L.push(`> ${LAST_SCOPE}`);
412
+ if (st.step === 'session') L.push('> 🟢=正在跑 ⚪=已休眠(都能绑,休眠的会被唤醒)');
413
+ L.push('');
414
+ if (st.error) L.push(`⚠️ ${st.error}`, '');
415
+ const opts = st.options;
416
+ if (!opts.length) {
417
+ L.push('这一步没扫到候选 —— 换个工作区试试,或直接说一声让本鲸手工绑。');
418
+ } else {
419
+ const total = Math.max(1, Math.ceil(opts.length / PAGE));
420
+ L.push(`第 ${st.page + 1}/${total} 页(点字母选中):`);
421
+ for (let i = 0; i < PAGE; i++) {
422
+ const idx = st.page * PAGE + i;
423
+ if (idx >= opts.length) break;
424
+ L.push(`**${LETTERS[i]}**. ${short(opts[idx].label, 46)}`);
425
+ }
426
+ }
427
+ return L.join('\n');
428
+ }
429
+
430
+ function cardKeyboard(st) {
431
+ const rows = [];
432
+ const opts = st.options;
433
+ const start = st.page * PAGE;
434
+ const cur = opts.slice(start, start + PAGE);
435
+ for (let r = 0; r * 5 < cur.length && r < 4; r++) {
436
+ const row = [];
437
+ for (let c = 0; c < 5; c++) {
438
+ const i = r * 5 + c;
439
+ if (i >= cur.length) break;
440
+ row.push(btn(`p${start + i}`, LETTERS[i]));
441
+ }
442
+ if (row.length) rows.push(row);
443
+ }
444
+ const nav = [];
445
+ if (st.page > 0) nav.push(btn('prev', '◀ 上一页', 1));
446
+ if (start + PAGE < opts.length) nav.push(btn('next', '下一页 ▶', 1));
447
+ rows.push(nav.length ? nav : [btn('noop', '·', 1)]);
448
+ rows.push([btn('cancel', '取消', 4)]);
449
+ return keyboardOf(rows, st.cardId);
450
+ }
451
+
452
+ /** 发/重发当前步骤的卡(★ 三件事全在这里做) */
453
+ async function paint(ctx, st) {
454
+ st.options = await optionsOf(ctx, st.step, st);
455
+ if (st.page * PAGE >= st.options.length) st.page = Math.max(0, Math.ceil(st.options.length / PAGE) - 1);
456
+ st.cardId = `${TOOL}-${++seq}-${Date.now().toString(36)}`;
457
+ const kb = cardKeyboard(st);
458
+ const ok = await sendCard(ctx, cardText(st), kb);
459
+ const caps = ctx?.caps ?? ctx ?? {};
460
+ const allIds = [...st.options.map((_, i) => `p${i}`), 'prev', 'next', 'cancel', 'noop'];
461
+ try {
462
+ if (typeof caps.registerInteractionCard === 'function') {
463
+ caps.registerInteractionCard({ cardId: st.cardId, buttonIds: allIds, expireAt: Date.now() + CARD_EXPIRE_MS });
464
+ }
465
+ } catch { /* ignore */ }
466
+ try { await regCardSelf(ctx, st.cardId, allIds); } catch { /* ignore */ }
467
+ W.set(st.cardId, st);
468
+ if (W.size > 30) {
469
+ for (const k of [...W.keys()].slice(0, W.size - 30)) if (k !== st.cardId) W.delete(k);
470
+ }
471
+ void diag(`[paint] step=${st.step} page=${st.page} 选项=${st.options.length} 发出=${ok ? 'ok' : 'FAIL'} 表=${W.size}`);
472
+ if (!ok) st.error = '卡片没发出去(当前环境可能不支持富文本卡片)';
473
+ return ok;
474
+ }
475
+
476
+ /**
477
+ * 全部选完 → **绑上回灌钩子** + **发可复制模板**。
478
+ * 「绑定任何已有会话」这件事本身不需要新能力:`register()` 的 childSessionId 就是"任意会话 id"。
479
+ */
480
+ async function bindNow(ctx, st) {
481
+ const lib = await loadLib();
482
+ const root = KERNEL ?? ctx?.caps?.kernel ?? ctx?.kernel ?? null;
483
+ const sess = st.picked.session ?? {};
484
+ const ws = st.picked.workspace?.value ?? '';
485
+ const mode = Number(st.picked.mode?.value ?? 1);
486
+ const parentId = CTX_CACHE.agentId || CTX_CACHE.sessionId || '';
487
+ void diag(`[bind] root=${!!root} parent=${parentId || '-'} child=${short(sess.value, 40)} mode=${mode}`);
488
+
489
+ if (!root || typeof root.on !== 'function') {
490
+ return { text: `⚠️ 拿不到宿主 ctx(需要权限包里的 kernel),这次没绑上。\n会话 id:\`${short(sess.value, 40)}\`` };
491
+ }
492
+ let result = '未挂(未知原因)';
493
+ try {
494
+ result = await lib.register(root, {
495
+ parentAgentId: parentId,
496
+ childSessionId: sess.value,
497
+ label: short(sess.label, 40),
498
+ mode,
499
+ sender: CTX_CACHE.sender,
500
+ replyTarget: CTX_CACHE.replyTarget,
501
+ name: short(sess.label, 40),
502
+ workspace: wsPath(ws),
503
+ });
504
+ } catch (e) {
505
+ result = '绑定抛错:' + short(e?.message ?? e, 120);
506
+ }
507
+
508
+ const sid = sess.value.startsWith('session-') ? sess.value.slice('session-'.length) : sess.value;
509
+ const key = sid.slice(0, 8);
510
+ const modeCn = mode === 2 ? '二档' : (mode === 3 ? '三档' : '一档');
511
+ const L = [];
512
+ L.push('### ✅ 已选定');
513
+ L.push('');
514
+ L.push(`- 会话:\`${short(sess.value, 44)}\``);
515
+ L.push(`- 工作区:${wsPath(ws)}`);
516
+ L.push(`- 回灌:**${modeCn}** · ${short(result, 60)}`);
517
+ L.push('');
518
+ L.push('**复制下面这行**,把文字改成你要发的内容就能路由给它(图片/引用也可以一起发):');
519
+ L.push('');
520
+ L.push(`\`[s:${key}]\` 在这里写你要发给这个会话的内容`);
521
+ L.push('');
522
+ L.push('> 直接发这句就行;本鲸会把标记剥掉、把内容投递进那个会话。');
523
+ return { text: L.join('\n') };
524
+ }
525
+
526
+ /** 推进到下一步(或收尾) */
527
+ async function advance(ctx, st) {
528
+ while (st.stepIdx < STEPS.length && st.picked[STEPS[st.stepIdx]]) st.stepIdx += 1;
529
+ if (st.stepIdx >= STEPS.length) {
530
+ W.delete(st.cardId);
531
+ const r = await bindNow(ctx, st);
532
+ try { const caps = ctx?.caps ?? ctx ?? {}; if (typeof caps.markdown === 'function') { await caps.markdown(r.text); return ''; } } catch { /* ignore */ }
533
+ return r.text;
534
+ }
535
+ st.step = STEPS[st.stepIdx];
536
+ st.page = 0;
537
+ st.error = '';
538
+ await paint(ctx, st);
539
+ return '';
540
+ }
541
+
542
+ export default {
543
+ name: ['会话列表', 'sessionlist', 'sessions'], description: '三步向导(工作区 → 该区会话 → 回灌模式)绑定任意已有会话,并给出可复制的路由模板',
544
+ usage: '/会话列表',
545
+
546
+ async handler(ctx) {
547
+ KERNEL = ctx?.caps?.kernel ?? ctx?.kernel ?? KERNEL;
548
+ CTX_CACHE.sender = ctx?.sender ?? ctx?.caps?.sender ?? CTX_CACHE.sender;
549
+ CTX_CACHE.replyTarget = ctx?.replyTarget ?? ctx?.caps?.replyTarget ?? CTX_CACHE.replyTarget;
550
+ try {
551
+ const caps = ctx?.caps ?? {};
552
+ const psid = String(caps.peerSessionId ?? '');
553
+ if (psid) { CTX_CACHE.agentId = psid; CTX_CACHE.sessionId = psid; }
554
+ if (!CTX_CACHE.sender) CTX_CACHE.sender = ctx?.bot ?? caps.sender ?? null;
555
+ void diag(`[handler] kernel=${!!KERNEL} sender=${!!CTX_CACHE.sender} rt=${!!CTX_CACHE.replyTarget} peerSessionId=${psid || '-'}`);
556
+ } catch { /* ignore */ }
557
+
558
+ const st = {
559
+ picked: {},
560
+ stepIdx: 0,
561
+ step: STEPS[0],
562
+ page: 0,
563
+ options: [],
564
+ cardId: '',
565
+ error: '',
566
+ };
567
+ W.set('pending', st);
568
+ await paint(ctx, st);
569
+ W.delete('pending');
570
+ W.set(st.cardId, st);
571
+ return '';
572
+ },
573
+
574
+ async onInteraction(ctx, info) {
575
+ const cardId = cn(info?.cardId);
576
+ const bid = cn(info?.buttonId);
577
+ const st = W.get(cardId);
578
+ void diag(`[onInteraction] card=${cardId} btn=${bid} 命中=${!!st} keys=[${[...W.keys()].join(',')}]`);
579
+ if (!st) return '这个向导已经过期了,重新发一次 /会话列表 吧~';
580
+ if (st.startedAt && Date.now() - st.startedAt > MAX_PAGE_MS) { W.delete(cardId); return '向导放太久了,重新发一次吧~'; }
581
+ st.startedAt = st.startedAt ?? Date.now();
582
+
583
+ if (bid === 'cancel') { W.delete(cardId); return '已取消,没有绑定任何会话。'; }
584
+ if (bid === 'noop') return '';
585
+ if (bid === 'prev' || bid === 'next') {
586
+ st.page = Math.max(0, st.page + (bid === 'next' ? 1 : -1));
587
+ const ok = await paint(ctx, st);
588
+ W.delete(cardId);
589
+ W.set(st.cardId, st);
590
+ return ok ? `已翻到第 ${st.page + 1} 页 ✅(新卡片在上面)` : `翻页失败(第 ${st.page + 1} 页)`;
591
+ }
592
+ if (/^p\d+$/.test(bid)) {
593
+ const idx = Number(bid.slice(1));
594
+ const opt = st.options[idx];
595
+ if (!opt) return '这个选项没了,翻一页再选~';
596
+ st.picked[st.step] = opt;
597
+ st.stepIdx += 1;
598
+ const done = await advance(ctx, st);
599
+ return done || '';
600
+ }
601
+ return '';
602
+ },
603
+ };