@zaofan/dsh-qqbot 1.10.2 → 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.
package/docs/USER-GUIDE.md
CHANGED
|
@@ -168,6 +168,35 @@ npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
|
|
|
168
168
|
- **怎么看有没有生效**:`session_watch action=list` 里每条盯梢末尾的 **`最近被引用=月-日 时:分`**,
|
|
169
169
|
就是最近一次引用回流的落点时间;从没被引用过则不显示。
|
|
170
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
|
+
|
|
171
200
|
### 🔨 锤子面板: 禁言 / 踢人 (v1.6.2+,2026-10-04 补充)
|
|
172
201
|
|
|
173
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
|
+
};
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
* 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
|
|
57
57
|
* 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
|
|
58
58
|
*/
|
|
59
|
-
export const __LIB_VERSION__ = 'lib-
|
|
59
|
+
export const __LIB_VERSION__ = 'lib-v13-20261014-通用路由([s:短id] / 【会话 id】)+扫盘解析+会话列表';
|
|
60
60
|
|
|
61
61
|
// ── ★ 2026-10-13 持久化改造(主人指示:**以本地存档为准,别只靠内存**)──────────────
|
|
62
62
|
// 本文件的风格是"用到时动态 import";这里用**顶层 await** 一次性取到同步版 fs,
|
|
63
63
|
// 后面的 ensureLoaded()(同步读存档)才能被同步函数(如 listWatching)直接调用。
|
|
64
|
-
const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
|
|
65
|
-
const { dirname } = await import('node:path');
|
|
64
|
+
const { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } = await import('node:fs');
|
|
65
|
+
const { dirname, join } = await import('node:path');
|
|
66
66
|
const { fileURLToPath } = await import('node:url');
|
|
67
67
|
|
|
68
68
|
/**
|
|
@@ -297,6 +297,102 @@ export function parseOwner(text) {
|
|
|
297
297
|
* 短 id → 完整会话 id。**当场查**,不依赖任何持久台账(主人 2026-10-12 明确口径)。
|
|
298
298
|
* 会话注册表的 API 形状随版本变,这里逐种试,全部失败就返回 ''。
|
|
299
299
|
*/
|
|
300
|
+
// ── ★ 2026-10-14 主人需求:**通用路由**(正文里带会话 id ⇒ 投递到那个会话)─────────────
|
|
301
|
+
/**
|
|
302
|
+
* 认两种写法(**不要求行首**,写在句子中间也认):
|
|
303
|
+
* `[s:xxxxxxxx]` ← 推荐;短 id(uuid 前 8 位)或完整 uuid 都行
|
|
304
|
+
* `【会话 xxxxxxxx】` ← 兼容"直接复制模板再改"
|
|
305
|
+
*
|
|
306
|
+
* 与 `routeQuoteBack`(引用回流)的分工:
|
|
307
|
+
* · `routeQuoteBack` 只认 `【子会话 xxxx】`,且**必须是已盯梢条目的父会话在说话** —— 语义是"回";
|
|
308
|
+
* · 本组函数认**任意 id**、**不需要绑定**、也不用先盯梢 —— 语义是"去"。
|
|
309
|
+
*/
|
|
310
|
+
const ROUTE_RE = /(?:\[s:|【\s*会话\s*)([0-9a-zA-Z][0-9a-zA-Z-]{2,63})\s*\]?】?/;
|
|
311
|
+
|
|
312
|
+
/** 从正文里抽路由目标 key(返回 '' = 这不是一条路由消息) */
|
|
313
|
+
export function parseRouteKey(text) {
|
|
314
|
+
const m = ROUTE_RE.exec(String(text ?? ''));
|
|
315
|
+
return m ? m[1] : '';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 剥掉路由标记 —— 送进目标会话的是"去掉标记后的正文" */
|
|
319
|
+
export function stripRouteMark(text) {
|
|
320
|
+
return String(text ?? '')
|
|
321
|
+
.replace(new RegExp(ROUTE_RE.source, 'g'), ' ')
|
|
322
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
323
|
+
.trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** `~/.dsh/sessions` 根目录(Windows 用 USERPROFILE,其余用 HOME) */
|
|
327
|
+
function sessionsRootDir() {
|
|
328
|
+
const home = String(process.env.USERPROFILE || process.env.HOME || '').trim();
|
|
329
|
+
return home ? join(home, '.dsh', 'sessions') : '';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* **扫盘**把短 id 解析成完整会话 id —— **不依赖对方是否 live**(这是"任何已有会话"的关键)。
|
|
334
|
+
* 目录形状:`~/.dsh/sessions/<工作区转义名>/<uuid>/`。
|
|
335
|
+
* @returns {{id: string, workspace: string}} 找不到时 id 为 ''
|
|
336
|
+
*/
|
|
337
|
+
export function findSessionOnDisk(key) {
|
|
338
|
+
const want = String(key ?? '').toLowerCase().trim();
|
|
339
|
+
if (!want) return { id: '', workspace: '' };
|
|
340
|
+
// 已经是完整 uuid ⇒ 直接认
|
|
341
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(want)) {
|
|
342
|
+
return { id: want, workspace: '' };
|
|
343
|
+
}
|
|
344
|
+
if (want.length < 4) return { id: '', workspace: '' };
|
|
345
|
+
const root = sessionsRootDir();
|
|
346
|
+
if (!root) return { id: '', workspace: '' };
|
|
347
|
+
try {
|
|
348
|
+
for (const ws of readdirSync(root)) {
|
|
349
|
+
let entries = [];
|
|
350
|
+
try { entries = readdirSync(join(root, ws)); } catch { continue; }
|
|
351
|
+
for (const d of entries) {
|
|
352
|
+
const raw = String(d).toLowerCase();
|
|
353
|
+
// ⚠️ 会话目录名**可能带前缀**(子会话是 `session-<uuid>`)⇒ 两边都过 shortSessionId 再比,
|
|
354
|
+
// 否则像 `cf871e99` 这种短 id 会解析失败(本轮实测踩到)。
|
|
355
|
+
const sh = shortSessionId(String(d)).toLowerCase();
|
|
356
|
+
if (sh.startsWith(want) || raw.startsWith(want)) return { id: String(d), workspace: String(ws) };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch { /* ignore */ }
|
|
360
|
+
return { id: '', workspace: '' };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 列会话目录(按 mtime 从新到旧)—— 给「/会话列表」命令用。
|
|
365
|
+
* @param {string} [workspaceDir] 传了就只列该工作区;不传列全部
|
|
366
|
+
*/
|
|
367
|
+
export function listSessionDirs(workspaceDir) {
|
|
368
|
+
const root = sessionsRootDir();
|
|
369
|
+
if (!root) return [];
|
|
370
|
+
const out = [];
|
|
371
|
+
const pushWs = (ws) => {
|
|
372
|
+
const wsDir = join(root, ws);
|
|
373
|
+
let entries = [];
|
|
374
|
+
try { entries = readdirSync(wsDir); } catch { return; }
|
|
375
|
+
for (const d of entries) {
|
|
376
|
+
const dir = join(wsDir, d);
|
|
377
|
+
let mtime = 0;
|
|
378
|
+
try { mtime = statSync(dir).mtimeMs; } catch { /* ignore */ }
|
|
379
|
+
out.push({ id: String(d), short: shortSessionId(String(d)), workspace: String(ws), mtime });
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
try {
|
|
383
|
+
if (workspaceDir) pushWs(workspaceDir);
|
|
384
|
+
else for (const ws of readdirSync(root)) pushWs(ws);
|
|
385
|
+
} catch { /* ignore */ }
|
|
386
|
+
return out.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 列出 `~/.dsh/sessions` 下的工作区转义目录名(给向导第一步用) */
|
|
390
|
+
export function listWorkspaceDirs() {
|
|
391
|
+
const root = sessionsRootDir();
|
|
392
|
+
if (!root) return [];
|
|
393
|
+
try { return readdirSync(root).filter((d) => !String(d).startsWith('.')); } catch { return []; }
|
|
394
|
+
}
|
|
395
|
+
|
|
300
396
|
export function resolveShortId(root, short) {
|
|
301
397
|
const want = String(short ?? '').toLowerCase();
|
|
302
398
|
if (!want) return '';
|
|
@@ -558,6 +654,108 @@ function parentEntryOf(sid) {
|
|
|
558
654
|
* 主人 2026-10-12 口径:**只靠行首的会话信息判断,不依赖台账**。
|
|
559
655
|
* 短 id → 完整 id 用 resolveShortId 当场查;查不到就静默放弃(绝不猜)。
|
|
560
656
|
*/
|
|
657
|
+
/**
|
|
658
|
+
* ★ 2026-10-14 主人需求:**通用路由** —— 把群里这条消息投递给"正文里点名的那个会话"。
|
|
659
|
+
*
|
|
660
|
+
* 流程:解析 key → 找会话(**先 live、后扫盘**)→ 投递(忙 `inject` 插话 / 闲 `followup` 唤醒)。
|
|
661
|
+
* 与 `routeQuoteBack` 的区别:**不限"已盯梢"、不限"父会话在说话"、认任意 id**。
|
|
662
|
+
*
|
|
663
|
+
* ★ 附件(2026-10-14 补):QQ 里**引用图片/文件**时,图**不在正文里** —— 它在入站事件的
|
|
664
|
+
* `attachments`(`{contentType,url,filename,asrText}`,见插件 `transport/inbound.ts`)。
|
|
665
|
+
* 只取文本会把图丢掉,所以这里额外把附件 URL/语音转写一并拼进载荷。
|
|
666
|
+
* @returns {Promise<{ok: boolean, reason: string, id?: string, workspace?: string, atts?: number}>}
|
|
667
|
+
*/
|
|
668
|
+
async function routeToSession(root, text, ev) {
|
|
669
|
+
try {
|
|
670
|
+
const key = parseRouteKey(text);
|
|
671
|
+
if (!key) return { ok: false, reason: 'no-key' };
|
|
672
|
+
const body = stripRouteMark(text);
|
|
673
|
+
// ① 先问宿主(快,只认 live)
|
|
674
|
+
let fullId = resolveShortId(root, key);
|
|
675
|
+
let workspace = '';
|
|
676
|
+
// ② live 查不到 ⇒ **扫盘**("任何已有会话"就靠这一步)
|
|
677
|
+
if (!fullId) {
|
|
678
|
+
const disk = findSessionOnDisk(key);
|
|
679
|
+
fullId = disk.id;
|
|
680
|
+
workspace = disk.workspace;
|
|
681
|
+
}
|
|
682
|
+
if (!fullId) return { ok: false, reason: 'not-found', id: key };
|
|
683
|
+
const agents = (() => { try { return root?.get?.('agents'); } catch { return null; } })();
|
|
684
|
+
let target = (() => { try { return agents?.get?.(fullId) ?? null; } catch { return null; } })();
|
|
685
|
+
// ③ 没活着 ⇒ 试着唤醒(宿主 agents.resume;拿不到就算了,绝不硬造 agent)
|
|
686
|
+
if (!target && agents && typeof agents.resume === 'function') {
|
|
687
|
+
try { target = await agents.resume(fullId); } catch { /* ignore */ }
|
|
688
|
+
}
|
|
689
|
+
if (!target) return { ok: false, reason: 'not-live', id: fullId, workspace };
|
|
690
|
+
// ④ 附件(图片/文件的 URL、语音转写)—— 引用图片时图就在这里
|
|
691
|
+
const atts = attachmentsOf(ev);
|
|
692
|
+
if (!body && !atts.length) return { ok: false, reason: 'empty-body', id: fullId, workspace };
|
|
693
|
+
const lines = ['【群里有人把这条消息路由给你】'];
|
|
694
|
+
if (body) lines.push(body);
|
|
695
|
+
if (atts.length) {
|
|
696
|
+
lines.push('');
|
|
697
|
+
lines.push('(随消息一起来的附件:)');
|
|
698
|
+
for (const a of atts) {
|
|
699
|
+
const kind = String(a?.contentType ?? '');
|
|
700
|
+
const url = String(a?.url ?? '');
|
|
701
|
+
const name = String(a?.filename ?? '');
|
|
702
|
+
const asr = String(a?.asrText ?? '');
|
|
703
|
+
const tag = /image/i.test(kind) ? '图片' : (/video/i.test(kind) ? '视频' : (/audio|voice/i.test(kind) ? '语音' : (/file|application/i.test(kind) ? '文件' : '附件')));
|
|
704
|
+
let line = `- ${tag}`;
|
|
705
|
+
if (name) line += `:${name}`;
|
|
706
|
+
if (url) line += `\n ${url}`;
|
|
707
|
+
if (asr) line += `\n (语音转写)${asr}`;
|
|
708
|
+
lines.push(line);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
lines.push('(这是跨会话路由,按需回应即可。)');
|
|
712
|
+
const msg = userText(lines.join('\n'));
|
|
713
|
+
if (!isInjectableMessage(msg)) return { ok: false, reason: 'bad-shape', id: fullId, workspace };
|
|
714
|
+
const busy = (() => {
|
|
715
|
+
try {
|
|
716
|
+
const st = String(target.status ?? target.phase ?? '');
|
|
717
|
+
return st !== '' && st !== 'idle';
|
|
718
|
+
} catch { return false; }
|
|
719
|
+
})();
|
|
720
|
+
if (busy) await target.inject(msg);
|
|
721
|
+
else await target.followup(msg);
|
|
722
|
+
return { ok: true, reason: busy ? 'inject' : 'followup', id: fullId, workspace, atts: atts.length };
|
|
723
|
+
} catch (e) {
|
|
724
|
+
return { ok: false, reason: 'error:' + (e instanceof Error ? e.message : String(e)) };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* 从入站事件里挖附件(多路兜底,形状照插件 `transport/inbound.ts` 的 `RawAttachment`)。
|
|
730
|
+
* 只保留有 url / asrText 的项,避免塞一堆空对象过去。
|
|
731
|
+
*/
|
|
732
|
+
export function attachmentsOf(ev) {
|
|
733
|
+
const cands = [
|
|
734
|
+
ev?.data?.message?.attachments,
|
|
735
|
+
ev?.data?.attachments,
|
|
736
|
+
ev?.message?.attachments,
|
|
737
|
+
ev?.attachments,
|
|
738
|
+
];
|
|
739
|
+
for (const c of cands) {
|
|
740
|
+
if (!Array.isArray(c) || c.length === 0) continue;
|
|
741
|
+
const out = [];
|
|
742
|
+
for (const a of c) {
|
|
743
|
+
if (!a || typeof a !== 'object') continue;
|
|
744
|
+
const url = String(a.url ?? a.URL ?? '').trim();
|
|
745
|
+
const asr = String(a.asrText ?? a.asr_text ?? '').trim();
|
|
746
|
+
if (!url && !asr) continue;
|
|
747
|
+
out.push({
|
|
748
|
+
contentType: String(a.contentType ?? a.content_type ?? ''),
|
|
749
|
+
url,
|
|
750
|
+
filename: String(a.filename ?? a.fileName ?? ''),
|
|
751
|
+
asrText: asr,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
if (out.length) return out;
|
|
755
|
+
}
|
|
756
|
+
return [];
|
|
757
|
+
}
|
|
758
|
+
|
|
561
759
|
async function routeQuoteBack(root, inboundText) {
|
|
562
760
|
const short = parseOwner(inboundText);
|
|
563
761
|
if (!short) return;
|
|
@@ -640,12 +838,25 @@ async function handler(root, session, event) {
|
|
|
640
838
|
|
|
641
839
|
// ── 引用回流:只关心**入站 user 消息**,且只在"某个盯梢对象的父会话"里认 ──
|
|
642
840
|
if (type === 'user/message') {
|
|
841
|
+
const t = textOf(event);
|
|
842
|
+
// ★ 2026-10-14:**通用路由优先** —— 正文里出现会话 id(`[s:xxx]` / `【会话 xxx】`)就直接投给
|
|
843
|
+
// 那个会话。**不限已盯梢、不限是不是父会话**(主人要的"任何已有会话都能被点名")。
|
|
844
|
+
if (t && parseRouteKey(t)) {
|
|
845
|
+
const r = await routeToSession(root, t, event);
|
|
846
|
+
try {
|
|
847
|
+
root?.logger?.info?.(
|
|
848
|
+
`[ext-hook:session-watch] 路由 ${r.ok ? '✅' : '✗'} ${r.reason}` +
|
|
849
|
+
(r.id ? ` → ${shortSessionId(r.id)}…${r.workspace ? `(${r.workspace})` : ''}` : '') +
|
|
850
|
+
(r.atts ? ` 附件${r.atts}个` : ''),
|
|
851
|
+
);
|
|
852
|
+
} catch { /* ignore */ }
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
643
855
|
const pe = parentEntryOf(sid);
|
|
644
856
|
if (pe) {
|
|
645
857
|
// ★ 2026-10-13:父会话一发言,就把重启后丢失的 QQ 转发能力补回来。
|
|
646
858
|
// 转发一断,群里就没有带「【子会话 xxxx】」行首的转发消息 ⇒ 引用回流也认不到路。
|
|
647
859
|
hydrateFwd(pe, root, session);
|
|
648
|
-
const t = textOf(event);
|
|
649
860
|
if (t && OWNER_RE.test(t)) await routeQuoteBack(root, t);
|
|
650
861
|
}
|
|
651
862
|
return;
|
|
@@ -1,180 +1,180 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sessions_list.mjs — 列出 DSH 的工作区与各会话(跨工作区 / 跨会话,纯只读)
|
|
3
|
-
*
|
|
4
|
-
* 定位:这是「把子代理那套搬到跨会话级」的第 1 块积木 —— 先能看见,再谈操作。
|
|
5
|
-
* expose: 'all' ⇒ 所有通道(QQ / Web / 终端…)的会话都能用;env.ctx 是宿主的 cordis ctx。
|
|
6
|
-
*
|
|
7
|
-
* 数据来源(都是宿主现成服务,不自己扫盘):
|
|
8
|
-
* · ctx.workspaceRegistry (@deepseek-ai/dsh-workspace) —— 有序、持久的工作区项目列表
|
|
9
|
-
* · ctx.sessionQuery (@deepseek-ai/dsh-session-query) —— 列会话(最新在前)、过滤、trace
|
|
10
|
-
* ⚠️ 取服务用 cordis 运行时 `ctx.get(name)`,不需要 inject(见 qqbot 的实测经验);
|
|
11
|
-
* 并且优先用 `ctx.root`(插件根作用域)—— 会话作用域的 ctx 会随会话切换而失效。
|
|
12
|
-
*
|
|
13
|
-
* 失败哲学:任何一个服务缺失都只降级、不报错中断 —— 把"哪些服务可用"如实报出来,
|
|
14
|
-
* 方便在别的 DSH 版本上继续排查。
|
|
15
|
-
*/
|
|
16
|
-
const WORKSPACE_SERVICE = 'workspaceRegistry';
|
|
17
|
-
const SESSION_SERVICE = 'sessionQuery';
|
|
18
|
-
/** 探测用的候选服务名(有就报出来,便于后续工具挑正确的那个) */
|
|
19
|
-
const PROBE = [WORKSPACE_SERVICE, SESSION_SERVICE, 'sessions', 'sessionPersistence', 'tools', 'systemPrompt', 'agents', 'goals'];
|
|
20
|
-
|
|
21
|
-
/** 宽松取字段:宿主返回结构可能随版本变,这里按候选名依次试 */
|
|
22
|
-
function pick(obj, names) {
|
|
23
|
-
if (!obj || typeof obj !== 'object') return undefined;
|
|
24
|
-
for (const n of names) {
|
|
25
|
-
const v = obj[n];
|
|
26
|
-
if (v !== undefined && v !== null) return v;
|
|
27
|
-
}
|
|
28
|
-
const h = obj.header;
|
|
29
|
-
if (h && typeof h === 'object') {
|
|
30
|
-
for (const n of names) {
|
|
31
|
-
const v = h[n];
|
|
32
|
-
if (v !== undefined && v !== null) return v;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** 时间戳 → 本地 MM-dd HH:mm(吃 number / string / Date) */
|
|
39
|
-
function fmtTime(v) {
|
|
40
|
-
if (v === undefined || v === null) return '';
|
|
41
|
-
try {
|
|
42
|
-
const d = v instanceof Date ? v : new Date(typeof v === 'number' ? v : String(v));
|
|
43
|
-
if (Number.isNaN(d.getTime())) return '';
|
|
44
|
-
// ⚠️ 用 UTC 字段显示:实测宿主把**本地时间**当 UTC 存
|
|
45
|
-
// (createdAt=1791762071432 → UTC 23:41 才与主人的实际时间对得上;按本地读会多 8 小时)。
|
|
46
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
47
|
-
return `${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
48
|
-
} catch { return ''; }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function safeGet(root, name) {
|
|
52
|
-
try { return root.get(name); } catch { return undefined; }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export default {
|
|
56
|
-
name: 'sessions_list',
|
|
57
|
-
description:
|
|
58
|
-
'列出 DSH 的工作区及其会话(跨工作区 / 跨会话):默认按最新活跃在前,可用 limit 控制条数,可只看某个工作区。纯只读。',
|
|
59
|
-
expose: 'all',
|
|
60
|
-
inputSchema: {
|
|
61
|
-
workspace: { type: 'string', description: '只看标题/路径里含这段文字的工作区(可选;不填=全部)' },
|
|
62
|
-
limit: { type: 'integer', description: '每个工作区最多列几个会话(默认 10,上限 50)' },
|
|
63
|
-
debug: { type: 'boolean', description: 'true=附上服务探测结果与一条原始会话记录(排查用,默认 false)' },
|
|
64
|
-
full: { type: 'boolean', description: 'true=显示完整 session id 与血缘(parentSession/origin/depth),编排用(默认 false=精简)' },
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
async run(args = {}, env = {}) {
|
|
68
|
-
const ctx = env?.ctx ?? null;
|
|
69
|
-
const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
|
|
70
|
-
if (!root || typeof root.get !== 'function') {
|
|
71
|
-
return { ok: false, msg: '拿不到宿主 ctx(env.ctx 缺失或没有 .get)—— 本工具需要 expose:\'all\' 才能在跨通道环境里拿到宿主句柄' };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const limit = Math.max(1, Math.min(50, Number(args?.limit) > 0 ? Math.floor(Number(args.limit)) : 10));
|
|
75
|
-
const filter = String(args?.workspace ?? '').trim().toLowerCase();
|
|
76
|
-
const lines = [];
|
|
77
|
-
const notes = [];
|
|
78
|
-
|
|
79
|
-
// ── 服务探测(如实报出可用性,缺了也能看出原因)──
|
|
80
|
-
const probe = {};
|
|
81
|
-
for (const n of PROBE) {
|
|
82
|
-
const s = safeGet(root, n);
|
|
83
|
-
probe[n] = s === undefined || s === null ? null : typeof s;
|
|
84
|
-
}
|
|
85
|
-
const reg = safeGet(root, WORKSPACE_SERVICE);
|
|
86
|
-
const sq = safeGet(root, SESSION_SERVICE);
|
|
87
|
-
|
|
88
|
-
// ── ① 工作区列表 ──
|
|
89
|
-
let projects = [];
|
|
90
|
-
if (reg && typeof reg.list === 'function') {
|
|
91
|
-
try { projects = reg.list() || []; } catch (e) { notes.push('workspaceRegistry.list() 抛错: ' + (e?.message ?? e)); }
|
|
92
|
-
} else if (!reg) {
|
|
93
|
-
notes.push(`宿主没挂 ${WORKSPACE_SERVICE} 服务(工作区分组不可用)`);
|
|
94
|
-
}
|
|
95
|
-
if (!Array.isArray(projects)) projects = [];
|
|
96
|
-
|
|
97
|
-
// ── ② 会话列表(sessionQuery 优先)──
|
|
98
|
-
let sessions = [];
|
|
99
|
-
if (sq && typeof sq.listSessions === 'function') {
|
|
100
|
-
try { sessions = (await sq.listSessions()) || []; } catch (e) { notes.push('sessionQuery.listSessions() 抛错: ' + (e?.message ?? e)); }
|
|
101
|
-
} else if (!sq) {
|
|
102
|
-
notes.push(`宿主没挂 ${SESSION_SERVICE} 服务(会话查询不可用)`);
|
|
103
|
-
}
|
|
104
|
-
if (!Array.isArray(sessions)) sessions = [];
|
|
105
|
-
|
|
106
|
-
// 会话按"最新活跃"排序:优先 lastActivity / updatedAt / 创建时间,取不到的排最后
|
|
107
|
-
const timeOf = (s) => {
|
|
108
|
-
const t = pick(s, ['lastActivity', 'lastActivityAt', 'updatedAt', 'lastMessageAt', 'createdAt', 'createdAtMs', 'timestamp', 'startedAt']);
|
|
109
|
-
const d = t instanceof Date ? t : new Date(typeof t === 'number' ? t : String(t ?? ''));
|
|
110
|
-
return Number.isNaN(d.getTime()) ? 0 : d.getTime();
|
|
111
|
-
};
|
|
112
|
-
sessions.sort((a, b) => timeOf(b) - timeOf(a));
|
|
113
|
-
|
|
114
|
-
// 归属:一个会话只归给它 cwd 最长的那个工作区(否则父目录 aiwork 会把子目录鲸鱼娘的会话全列走)
|
|
115
|
-
const owners = new Map();
|
|
116
|
-
for (const s of sessions) {
|
|
117
|
-
const cwd = String(pick(s, ['cwd', 'workdir', 'directory', 'workingDirectory']) ?? '');
|
|
118
|
-
if (!cwd) continue;
|
|
119
|
-
let best = null;
|
|
120
|
-
for (const p of projects) {
|
|
121
|
-
const pd = String(pick(p, ['path', 'directory', 'dir', 'cwd']) ?? '');
|
|
122
|
-
if (!pd) continue;
|
|
123
|
-
if (cwd === pd) { const hit = pd.length + 10000; if (!best || hit > best.len) best = { len: hit, p }; continue; }
|
|
124
|
-
if (cwd.startsWith(pd + '\\') || cwd.startsWith(pd + '/')) { const hit = pd.length; if (!best || hit > best.len) best = { len: hit, p }; }
|
|
125
|
-
}
|
|
126
|
-
if (best) owners.set(s, best.p);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const full = !!args?.full;
|
|
130
|
-
const line = (s) => {
|
|
131
|
-
const id = String(pick(s, ['id', 'sessionId', 'sid']) ?? '?');
|
|
132
|
-
const title = String(pick(s, ['title', 'name', 'summary']) ?? '').slice(0, 40);
|
|
133
|
-
const live = pick(s, ['live', 'isLive']) ? 'live' : 'persisted';
|
|
134
|
-
const t = fmtTime(pick(s, ['lastActivity', 'lastActivityAt', 'updatedAt', 'createdAt', 'createdAtMs', 'startedAt']));
|
|
135
|
-
const short = id.length > 14 && !full ? id.slice(0, 8) + '…' + id.slice(-4) : id;
|
|
136
|
-
// 血缘(子代理实测建议:跨会话编排必须看到 parent/origin/depth,否则只能另开 debug 手工比对)
|
|
137
|
-
const origin = pick(s, ['origin']) ?? '';
|
|
138
|
-
const depth = pick(s, ['delegationDepth']);
|
|
139
|
-
const parent = String(pick(s, ['parentSession']) ?? '');
|
|
140
|
-
const lin = origin
|
|
141
|
-
? ` origin=${origin}${depth !== undefined ? ' depth=' + depth : ''}${parent ? ' parent=' + (full ? parent : parent.slice(0, 8) + '…') : ''}`
|
|
142
|
-
: '';
|
|
143
|
-
return ` · ${t || ' '} [${live}] ${short}${title ? ' ' + title : ''}${lin}`;
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
// 有 workspaceRegistry 就按"目录 → 它的会话"分组;否则退化成一张平表
|
|
147
|
-
if (projects.length) {
|
|
148
|
-
for (const p of projects) {
|
|
149
|
-
const ptitle = String(pick(p, ['title', 'name']) ?? '(未命名)');
|
|
150
|
-
const pdir = String(pick(p, ['path', 'directory', 'dir', 'cwd']) ?? '');
|
|
151
|
-
if (filter && !(ptitle.toLowerCase().includes(filter) || pdir.toLowerCase().includes(filter))) continue;
|
|
152
|
-
const mine = sessions.filter((s) => owners.get(s) === p);
|
|
153
|
-
lines.push(`${ptitle}${pdir ? ' (' + pdir + ')' : ''} —— ${mine.length} 个会话`);
|
|
154
|
-
if (!mine.length) lines.push(' · (这个工作区下没有可列的会话)');
|
|
155
|
-
for (const s of mine.slice(0, limit)) lines.push(line(s));
|
|
156
|
-
if (mine.length > limit) lines.push(` … 还有 ${mine.length - limit} 个(用 limit 调整)`);
|
|
157
|
-
}
|
|
158
|
-
// 不属于任何工作区的会话
|
|
159
|
-
const ungrouped = sessions.filter((s) => !owners.get(s));
|
|
160
|
-
if (ungrouped.length && !filter) {
|
|
161
|
-
lines.push(`(不属于任何工作区的会话) —— ${ungrouped.length} 个`);
|
|
162
|
-
for (const s of ungrouped.slice(0, limit)) lines.push(line(s));
|
|
163
|
-
}
|
|
164
|
-
} else {
|
|
165
|
-
lines.push(`(工作区分组不可用,退化成会话平表)共 ${sessions.length} 个`);
|
|
166
|
-
for (const s of sessions.slice(0, limit)) lines.push(line(s));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// 注(2026-10-12 主人定):**不再**在首行显示"我 / 父代"。
|
|
170
|
-
// 会话来源改用另一条更直接的约定:**模拟用户消息的首行直接给出源会话 id**
|
|
171
|
-
// (见 session_send 与 lib/session_watch_lib.mjs)。
|
|
172
|
-
const head = `【DSH 工作区与会话】 工作区 ${projects.length} 个 / 会话 ${sessions.length} 个${filter ? `(筛选: ${args.workspace})` : ''}`;
|
|
173
|
-
const tail = notes.length ? '\n' + notes.map((n) => '⚠ ' + n).join('\n') : '';
|
|
174
|
-
const dbg = args?.debug
|
|
175
|
-
? '\n— 服务探测: ' + Object.entries(probe).map(([k, v]) => `${k}=${v ?? '✗'}`).join(', ') +
|
|
176
|
-
'\n— 首条会话原始结构: ' + JSON.stringify(sessions[0] ?? null).slice(0, 600)
|
|
177
|
-
: '';
|
|
178
|
-
return { ok: true, msg: [head, ...lines].join('\n') + tail + dbg };
|
|
179
|
-
},
|
|
180
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* sessions_list.mjs — 列出 DSH 的工作区与各会话(跨工作区 / 跨会话,纯只读)
|
|
3
|
+
*
|
|
4
|
+
* 定位:这是「把子代理那套搬到跨会话级」的第 1 块积木 —— 先能看见,再谈操作。
|
|
5
|
+
* expose: 'all' ⇒ 所有通道(QQ / Web / 终端…)的会话都能用;env.ctx 是宿主的 cordis ctx。
|
|
6
|
+
*
|
|
7
|
+
* 数据来源(都是宿主现成服务,不自己扫盘):
|
|
8
|
+
* · ctx.workspaceRegistry (@deepseek-ai/dsh-workspace) —— 有序、持久的工作区项目列表
|
|
9
|
+
* · ctx.sessionQuery (@deepseek-ai/dsh-session-query) —— 列会话(最新在前)、过滤、trace
|
|
10
|
+
* ⚠️ 取服务用 cordis 运行时 `ctx.get(name)`,不需要 inject(见 qqbot 的实测经验);
|
|
11
|
+
* 并且优先用 `ctx.root`(插件根作用域)—— 会话作用域的 ctx 会随会话切换而失效。
|
|
12
|
+
*
|
|
13
|
+
* 失败哲学:任何一个服务缺失都只降级、不报错中断 —— 把"哪些服务可用"如实报出来,
|
|
14
|
+
* 方便在别的 DSH 版本上继续排查。
|
|
15
|
+
*/
|
|
16
|
+
const WORKSPACE_SERVICE = 'workspaceRegistry';
|
|
17
|
+
const SESSION_SERVICE = 'sessionQuery';
|
|
18
|
+
/** 探测用的候选服务名(有就报出来,便于后续工具挑正确的那个) */
|
|
19
|
+
const PROBE = [WORKSPACE_SERVICE, SESSION_SERVICE, 'sessions', 'sessionPersistence', 'tools', 'systemPrompt', 'agents', 'goals'];
|
|
20
|
+
|
|
21
|
+
/** 宽松取字段:宿主返回结构可能随版本变,这里按候选名依次试 */
|
|
22
|
+
function pick(obj, names) {
|
|
23
|
+
if (!obj || typeof obj !== 'object') return undefined;
|
|
24
|
+
for (const n of names) {
|
|
25
|
+
const v = obj[n];
|
|
26
|
+
if (v !== undefined && v !== null) return v;
|
|
27
|
+
}
|
|
28
|
+
const h = obj.header;
|
|
29
|
+
if (h && typeof h === 'object') {
|
|
30
|
+
for (const n of names) {
|
|
31
|
+
const v = h[n];
|
|
32
|
+
if (v !== undefined && v !== null) return v;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 时间戳 → 本地 MM-dd HH:mm(吃 number / string / Date) */
|
|
39
|
+
function fmtTime(v) {
|
|
40
|
+
if (v === undefined || v === null) return '';
|
|
41
|
+
try {
|
|
42
|
+
const d = v instanceof Date ? v : new Date(typeof v === 'number' ? v : String(v));
|
|
43
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
44
|
+
// ⚠️ 用 UTC 字段显示:实测宿主把**本地时间**当 UTC 存
|
|
45
|
+
// (createdAt=1791762071432 → UTC 23:41 才与主人的实际时间对得上;按本地读会多 8 小时)。
|
|
46
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
47
|
+
return `${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
48
|
+
} catch { return ''; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeGet(root, name) {
|
|
52
|
+
try { return root.get(name); } catch { return undefined; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default {
|
|
56
|
+
name: 'sessions_list',
|
|
57
|
+
description:
|
|
58
|
+
'列出 DSH 的工作区及其会话(跨工作区 / 跨会话):默认按最新活跃在前,可用 limit 控制条数,可只看某个工作区。纯只读。',
|
|
59
|
+
expose: 'all',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
workspace: { type: 'string', description: '只看标题/路径里含这段文字的工作区(可选;不填=全部)' },
|
|
62
|
+
limit: { type: 'integer', description: '每个工作区最多列几个会话(默认 10,上限 50)' },
|
|
63
|
+
debug: { type: 'boolean', description: 'true=附上服务探测结果与一条原始会话记录(排查用,默认 false)' },
|
|
64
|
+
full: { type: 'boolean', description: 'true=显示完整 session id 与血缘(parentSession/origin/depth),编排用(默认 false=精简)' },
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async run(args = {}, env = {}) {
|
|
68
|
+
const ctx = env?.ctx ?? null;
|
|
69
|
+
const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
|
|
70
|
+
if (!root || typeof root.get !== 'function') {
|
|
71
|
+
return { ok: false, msg: '拿不到宿主 ctx(env.ctx 缺失或没有 .get)—— 本工具需要 expose:\'all\' 才能在跨通道环境里拿到宿主句柄' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const limit = Math.max(1, Math.min(50, Number(args?.limit) > 0 ? Math.floor(Number(args.limit)) : 10));
|
|
75
|
+
const filter = String(args?.workspace ?? '').trim().toLowerCase();
|
|
76
|
+
const lines = [];
|
|
77
|
+
const notes = [];
|
|
78
|
+
|
|
79
|
+
// ── 服务探测(如实报出可用性,缺了也能看出原因)──
|
|
80
|
+
const probe = {};
|
|
81
|
+
for (const n of PROBE) {
|
|
82
|
+
const s = safeGet(root, n);
|
|
83
|
+
probe[n] = s === undefined || s === null ? null : typeof s;
|
|
84
|
+
}
|
|
85
|
+
const reg = safeGet(root, WORKSPACE_SERVICE);
|
|
86
|
+
const sq = safeGet(root, SESSION_SERVICE);
|
|
87
|
+
|
|
88
|
+
// ── ① 工作区列表 ──
|
|
89
|
+
let projects = [];
|
|
90
|
+
if (reg && typeof reg.list === 'function') {
|
|
91
|
+
try { projects = reg.list() || []; } catch (e) { notes.push('workspaceRegistry.list() 抛错: ' + (e?.message ?? e)); }
|
|
92
|
+
} else if (!reg) {
|
|
93
|
+
notes.push(`宿主没挂 ${WORKSPACE_SERVICE} 服务(工作区分组不可用)`);
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(projects)) projects = [];
|
|
96
|
+
|
|
97
|
+
// ── ② 会话列表(sessionQuery 优先)──
|
|
98
|
+
let sessions = [];
|
|
99
|
+
if (sq && typeof sq.listSessions === 'function') {
|
|
100
|
+
try { sessions = (await sq.listSessions()) || []; } catch (e) { notes.push('sessionQuery.listSessions() 抛错: ' + (e?.message ?? e)); }
|
|
101
|
+
} else if (!sq) {
|
|
102
|
+
notes.push(`宿主没挂 ${SESSION_SERVICE} 服务(会话查询不可用)`);
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
105
|
+
|
|
106
|
+
// 会话按"最新活跃"排序:优先 lastActivity / updatedAt / 创建时间,取不到的排最后
|
|
107
|
+
const timeOf = (s) => {
|
|
108
|
+
const t = pick(s, ['lastActivity', 'lastActivityAt', 'updatedAt', 'lastMessageAt', 'createdAt', 'createdAtMs', 'timestamp', 'startedAt']);
|
|
109
|
+
const d = t instanceof Date ? t : new Date(typeof t === 'number' ? t : String(t ?? ''));
|
|
110
|
+
return Number.isNaN(d.getTime()) ? 0 : d.getTime();
|
|
111
|
+
};
|
|
112
|
+
sessions.sort((a, b) => timeOf(b) - timeOf(a));
|
|
113
|
+
|
|
114
|
+
// 归属:一个会话只归给它 cwd 最长的那个工作区(否则父目录 aiwork 会把子目录鲸鱼娘的会话全列走)
|
|
115
|
+
const owners = new Map();
|
|
116
|
+
for (const s of sessions) {
|
|
117
|
+
const cwd = String(pick(s, ['cwd', 'workdir', 'directory', 'workingDirectory']) ?? '');
|
|
118
|
+
if (!cwd) continue;
|
|
119
|
+
let best = null;
|
|
120
|
+
for (const p of projects) {
|
|
121
|
+
const pd = String(pick(p, ['path', 'directory', 'dir', 'cwd']) ?? '');
|
|
122
|
+
if (!pd) continue;
|
|
123
|
+
if (cwd === pd) { const hit = pd.length + 10000; if (!best || hit > best.len) best = { len: hit, p }; continue; }
|
|
124
|
+
if (cwd.startsWith(pd + '\\') || cwd.startsWith(pd + '/')) { const hit = pd.length; if (!best || hit > best.len) best = { len: hit, p }; }
|
|
125
|
+
}
|
|
126
|
+
if (best) owners.set(s, best.p);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const full = !!args?.full;
|
|
130
|
+
const line = (s) => {
|
|
131
|
+
const id = String(pick(s, ['id', 'sessionId', 'sid']) ?? '?');
|
|
132
|
+
const title = String(pick(s, ['title', 'name', 'summary']) ?? '').slice(0, 40);
|
|
133
|
+
const live = pick(s, ['live', 'isLive']) ? 'live' : 'persisted';
|
|
134
|
+
const t = fmtTime(pick(s, ['lastActivity', 'lastActivityAt', 'updatedAt', 'createdAt', 'createdAtMs', 'startedAt']));
|
|
135
|
+
const short = id.length > 14 && !full ? id.slice(0, 8) + '…' + id.slice(-4) : id;
|
|
136
|
+
// 血缘(子代理实测建议:跨会话编排必须看到 parent/origin/depth,否则只能另开 debug 手工比对)
|
|
137
|
+
const origin = pick(s, ['origin']) ?? '';
|
|
138
|
+
const depth = pick(s, ['delegationDepth']);
|
|
139
|
+
const parent = String(pick(s, ['parentSession']) ?? '');
|
|
140
|
+
const lin = origin
|
|
141
|
+
? ` origin=${origin}${depth !== undefined ? ' depth=' + depth : ''}${parent ? ' parent=' + (full ? parent : parent.slice(0, 8) + '…') : ''}`
|
|
142
|
+
: '';
|
|
143
|
+
return ` · ${t || ' '} [${live}] ${short}${title ? ' ' + title : ''}${lin}`;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// 有 workspaceRegistry 就按"目录 → 它的会话"分组;否则退化成一张平表
|
|
147
|
+
if (projects.length) {
|
|
148
|
+
for (const p of projects) {
|
|
149
|
+
const ptitle = String(pick(p, ['title', 'name']) ?? '(未命名)');
|
|
150
|
+
const pdir = String(pick(p, ['path', 'directory', 'dir', 'cwd']) ?? '');
|
|
151
|
+
if (filter && !(ptitle.toLowerCase().includes(filter) || pdir.toLowerCase().includes(filter))) continue;
|
|
152
|
+
const mine = sessions.filter((s) => owners.get(s) === p);
|
|
153
|
+
lines.push(`${ptitle}${pdir ? ' (' + pdir + ')' : ''} —— ${mine.length} 个会话`);
|
|
154
|
+
if (!mine.length) lines.push(' · (这个工作区下没有可列的会话)');
|
|
155
|
+
for (const s of mine.slice(0, limit)) lines.push(line(s));
|
|
156
|
+
if (mine.length > limit) lines.push(` … 还有 ${mine.length - limit} 个(用 limit 调整)`);
|
|
157
|
+
}
|
|
158
|
+
// 不属于任何工作区的会话
|
|
159
|
+
const ungrouped = sessions.filter((s) => !owners.get(s));
|
|
160
|
+
if (ungrouped.length && !filter) {
|
|
161
|
+
lines.push(`(不属于任何工作区的会话) —— ${ungrouped.length} 个`);
|
|
162
|
+
for (const s of ungrouped.slice(0, limit)) lines.push(line(s));
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
lines.push(`(工作区分组不可用,退化成会话平表)共 ${sessions.length} 个`);
|
|
166
|
+
for (const s of sessions.slice(0, limit)) lines.push(line(s));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 注(2026-10-12 主人定):**不再**在首行显示"我 / 父代"。
|
|
170
|
+
// 会话来源改用另一条更直接的约定:**模拟用户消息的首行直接给出源会话 id**
|
|
171
|
+
// (见 session_send 与 lib/session_watch_lib.mjs)。
|
|
172
|
+
const head = `【DSH 工作区与会话】 工作区 ${projects.length} 个 / 会话 ${sessions.length} 个${filter ? `(筛选: ${args.workspace})` : ''}`;
|
|
173
|
+
const tail = notes.length ? '\n' + notes.map((n) => '⚠ ' + n).join('\n') : '';
|
|
174
|
+
const dbg = args?.debug
|
|
175
|
+
? '\n— 服务探测: ' + Object.entries(probe).map(([k, v]) => `${k}=${v ?? '✗'}`).join(', ') +
|
|
176
|
+
'\n— 首条会话原始结构: ' + JSON.stringify(sessions[0] ?? null).slice(0, 600)
|
|
177
|
+
: '';
|
|
178
|
+
return { ok: true, msg: [head, ...lines].join('\n') + tail + dbg };
|
|
179
|
+
},
|
|
180
|
+
};
|