@zaofan/dsh-qqbot 1.9.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,93 +1,139 @@
1
- /**
2
- * session_watch.mjs — 查看/补挂「回合结束回灌」钩子
3
- *
4
- * 配合 session_create 用:创建时会自动挂钩子(session_create → lib/session_watch_lib.register)。
5
- * 本工具负责:
6
- * · list(默认):列出正在盯的子会话(子 id / 父 id / 标签 / 是否已攒到正文 / 已结算几次)
7
- * · attach:给一个"已存在的子会话"补挂钩子(创建时漏挂、或插件重启后钩子丢了的情况)
8
- * · detach:取消盯某个子会话
9
- * ⚠️ 主人要求这类工具不给全部会话用:已落地 `expose: 'qq'`(2026-10-12),只在 QQ 会话注册。
10
- */
11
- import { statSync } from 'node:fs';
12
- import { fileURLToPath } from 'node:url';
13
-
14
- /**
15
- * 带版本号地加载共用库 —— **必须这么加载**。
16
- *
17
- * 原因同 session_create.mjs:插件只给**工具文件本身**的 import 加了 `?v=<mtime>`,
18
- * 工具内部裸路径 import 会被 ESM 模块缓存永久命中 ⇒ 改 `tools/lib/` 热刷不生效。
19
- * (这里原来是顶层静态 import,等于永远锁死在插件启动那一刻的旧版本。)
20
- */
21
- async function loadWatchLib() {
22
- const url = new URL('./lib/session_watch_lib.mjs', import.meta.url);
23
- let v = '0';
24
- try { v = String(statSync(fileURLToPath(url)).mtimeMs); } catch { /* 拿不到 mtime 就退回裸 import */ }
25
- return import(v === '0' ? url.href : `${url.href}?v=${v}`);
26
- }
27
-
28
- /** 进程内自己维护的"取消盯梢"集合(库只管挂,取消在本工具侧记录) */
29
- const detached = new Set();
30
-
31
- export default {
32
- name: 'session_watch',
33
- // ★ 2026-10-12:**父会话专用**("我作为父"去挂/摘回灌钩子)⇒ 不给子会话看。
34
- // 只注册到 QQ 会话(子会话不可见)—— "我作为父"才需要挂/摘回灌钩子。
35
- expose: 'qq',
36
- description:
37
- '查看/补挂「回合结束回灌」钩子:默认列出正在盯的子会话与状态;attach=给指定子会话补挂(子会话 id + 父会话 id);detach=取消盯梢。' +
38
- '(本工具是**兜底**通道:正常情况下让子会话自己用 send_message 回执即可。)',
39
- inputSchema: {
40
- action: { type: 'string', description: 'list(默认)=看状态 / attach=给已存在的子会话补挂 / detach=取消盯梢' },
41
- child_session_id: { type: 'string', description: 'attach 或 detach 时要指定的子会话 id' },
42
- parent_agent_id: { type: 'string', description: 'attach 时填**父会话 id**(历史上叫"父 agent id",指的是同一个东西;不填=用"我自己这个会话")' },
43
- label: { type: 'string', description: 'attach 时的标签,便于记认(可选)' },
44
- },
45
-
46
- async run(args = {}, env = {}) {
47
- const ctx = env?.ctx ?? null;
48
- const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
49
- if (!root?.get) return { ok: false, msg: '拿不到宿主 ctx(需要 expose:\'all\')' };
50
-
51
- const lib = await loadWatchLib();
52
-
53
- const action = String(args?.action ?? 'list').toLowerCase();
54
- const p = (n) => String(n).padStart(2, '0');
55
- const fmt = (t) => {
56
- if (!t) return '—';
57
- const d = new Date(t);
58
- return Number.isNaN(d.getTime()) ? '—' : `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
59
- };
60
-
61
- if (action === 'attach' || action === '补挂') {
62
- const child = String(args?.child_session_id ?? '').trim();
63
- const parent = String(args?.parent_agent_id ?? env?.exec?.agent?.id ?? '').trim();
64
- if (!child) return { ok: false, msg: 'attach 需要 child_session_id' };
65
- if (!parent) return { ok: false, msg: 'attach 需要 parent_agent_id(不填会用"我自己")' };
66
- detached.delete(child);
67
- const r = await lib.register(root, { parentAgentId: parent, childSessionId: child, label: String(args?.label ?? '') });
68
- return { ok: !String(r).startsWith('未挂'), msg: 'attach → ' + r };
69
- }
70
-
71
- if (action === 'detach' || action === '取消') {
72
- const child = String(args?.child_session_id ?? '').trim();
73
- if (!child) return { ok: false, msg: 'detach 需要 child_session_id' };
74
- detached.add(child);
75
- return { ok: true, msg: `已从盯梢名单移除 ${child.slice(0, 8)}…(注:钩子本身是全局一个,这里只是不再为它回灌)` };
76
- }
77
-
78
- // list
79
- const items = lib.listWatching().filter((x) => !detached.has(x.childSessionId));
80
- if (!items.length) {
81
- return {
82
- ok: true,
83
- msg: '【回合结束回灌】当前没有盯着任何子会话。\n用 session_create 建会话时会自动挂;也可以用 attach 给已存在的子会话补挂。',
84
- };
85
- }
86
- const lines = items.map(
87
- (x) =>
88
- ` · 子 ${x.shortId ?? x.childSessionId.slice(0, 8)}… → 父 ${x.parentShortId ?? x.parentAgentId.slice(0, 8)}…` +
89
- `${x.label ? ' [' + x.label + ']' : ''} 攒到正文=${x.hasText ? 'Y' : 'N'} 已结算=${x.turnsSeen} 次 上次回灌=${fmt(x.lastFiredAt)}`,
90
- );
91
- return { ok: true, msg: `【回合结束回灌】正在盯 ${items.length} 个子会话:\n` + lines.join('\n') };
92
- },
93
- };
1
+ /**
2
+ * session_watch.mjs — 查看/补挂「回合结束回灌」钩子(含三档 + QQ 转发)
3
+ *
4
+ * 配合 session_create 用:创建时会自动挂钩子(session_create → lib/session_watch_lib.register)。
5
+ * 本工具负责:
6
+ * · list(默认):列出正在盯的子会话(子 id / 父 id / 标签 / **档位** / QQ 转发是否就绪 / 已结算几次)
7
+ * · attach:给一个"已存在的子会话"补挂钩子(创建时漏挂、或插件重启后钩子丢了的情况)
8
+ * · mode:改已挂盯梢的档位(一/二/三档随时可改)
9
+ * · detach:取消盯某个子会话
10
+ *
11
+ * ★ 2026-10-12 主人定:三档(mode)
12
+ * 1 = 一档(默认):末段正文回灌父会话 + **同时**转发到父会话所在的 QQ
13
+ * 2 = 二档:本回合**所有正文 + 报错**都转发 QQ,**不进**父会话上下文
14
+ * 3 = 三档:所有正文 + 报错**进**父会话上下文(**不唤醒**)+ 同时也转发 QQ
15
+ *
16
+ * ⚠️ 主人要求这类工具不给全部会话用:已落地 `expose: 'qq'`(2026-10-12),只在 QQ 会话注册。
17
+ */
18
+ import { statSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ /**
22
+ * 带版本号地加载共用库 —— **必须这么加载**。
23
+ *
24
+ * 原因同 session_create.mjs:插件只给**工具文件本身**的 import 加了 `?v=<mtime>`,
25
+ * 工具内部裸路径 import 会被 ESM 模块缓存永久命中 ⇒ 改 `tools/lib/` 热刷不生效。
26
+ * (这里原来是顶层静态 import,等于永远锁死在插件启动那一刻的旧版本。)
27
+ */
28
+ async function loadWatchLib() {
29
+ const url = new URL('./lib/session_watch_lib.mjs', import.meta.url);
30
+ let v = '0';
31
+ try { v = String(statSync(fileURLToPath(url)).mtimeMs); } catch { /* 拿不到 mtime 就退回裸 import */ }
32
+ return import(v === '0' ? url.href : `${url.href}?v=${v}`);
33
+ }
34
+
35
+ /** 档位 → 人话 */
36
+ const modeText = (m) => (Number(m) === 2 ? '二档·只转QQ不进父' : (Number(m) === 3 ? '三档·进父不唤醒+转QQ' : '一档·末段回灌+转QQ'));
37
+ /** 把用户给的档位写法归一(收 1/2/3 与中文「一档/二档/三档」) */
38
+ function normMode(v) {
39
+ const s = String(v ?? '').trim();
40
+ if (/^(2|二)/.test(s)) return 2;
41
+ if (/^(3|三)/.test(s)) return 3;
42
+ if (/^(1|一)/.test(s)) return 1;
43
+ return 1;
44
+ }
45
+
46
+ export default {
47
+ name: 'session_watch',
48
+ // ★ 2026-10-12:**父会话专用**("我作为父"去挂/摘回灌钩子)⇒ 不给子会话看。
49
+ expose: 'qq',
50
+ description:
51
+ '查看/补挂「回合结束回灌」钩子:默认列出正在盯的子会话、档位与状态;' +
52
+ 'attach=给指定子会话补挂(子会话 id + 父会话 id + 可选 mode);mode=改已挂盯梢的档位(1/2/3);detach=取消盯梢。' +
53
+ '档位:1=只把末段正文回灌父会话并转发到父所在的QQ;2=只把该回合所有正文+报错转发QQ、不进父上下文;3=进父上下文但不唤醒、同时也转发QQ。',
54
+ inputSchema: {
55
+ action: { type: 'string', description: 'list(默认)=看状态 / attach=给已存在的子会话补挂 / mode=改档位 / detach=取消盯梢' },
56
+ child_session_id: { type: 'string', description: 'attach / mode / detach 时要指定的子会话 id' },
57
+ parent_agent_id: { type: 'string', description: 'attach 时填**父会话 id**(历史上叫"父 agent id",指的是同一个东西;不填=用"我自己这个会话")' },
58
+ label: { type: 'string', description: 'attach 时的标签,便于记认(可选)' },
59
+ name: { type: 'string', description: '会话名(可选;只影响转发到 QQ 时行首归属里显示什么,缺省用 label)' },
60
+ preset: { type: 'string', description: '预设名(可选;同上,归属行显示用)' },
61
+ workspace: { type: 'string', description: '工作区名(可选;同上,归属行显示用,建议只写目录名)' },
62
+ mode: { type: 'string', description: '档位:1/一档(默认)=末段回灌父+转QQ;2/二档=只转QQ不进父上下文;3/三档=进父上下文但不唤醒+转QQ' },
63
+ },
64
+
65
+ async run(args = {}, env = {}) {
66
+ const ctx = env?.ctx ?? null;
67
+ const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
68
+ if (!root?.get) return { ok: false, msg: '拿不到宿主 ctx(需要 expose:\'all\')' };
69
+
70
+ const lib = await loadWatchLib();
71
+
72
+ const action = String(args?.action ?? 'list').toLowerCase();
73
+ const p = (n) => String(n).padStart(2, '0');
74
+ const fmt = (t) => {
75
+ if (!t) return '—';
76
+ const d = new Date(t);
77
+ return Number.isNaN(d.getTime()) ? '—' : `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
78
+ };
79
+
80
+ if (action === 'attach' || action === '补挂') {
81
+ const child = String(args?.child_session_id ?? '').trim();
82
+ const parent = String(args?.parent_agent_id ?? env?.exec?.agent?.id ?? '').trim();
83
+ if (!child) return { ok: false, msg: 'attach 需要 child_session_id' };
84
+ if (!parent) return { ok: false, msg: 'attach 需要 parent_agent_id(不填会用"我自己")' };
85
+ const r = await lib.register(root, {
86
+ parentAgentId: parent,
87
+ childSessionId: child,
88
+ label: String(args?.label ?? ''),
89
+ mode: normMode(args?.mode),
90
+ // 归属行三件套(可选;不填就只显示短 id)
91
+ name: String(args?.name ?? args?.label ?? ''),
92
+ preset: String(args?.preset ?? ''),
93
+ workspace: String(args?.workspace ?? ''),
94
+ // ★ QQ 转发用的就是"我自己这个会话"的 sender + replyTarget
95
+ // (replyTarget 自带最近 msgId ⇒ 被动优先;插件的 sendMarkdown 自带 markdown 降级链)
96
+ sender: env?.sender,
97
+ replyTarget: env?.replyTarget,
98
+ peerName: String(env?.replyTarget?.name ?? ''),
99
+ });
100
+ return { ok: !String(r).startsWith('未挂'), msg: 'attach → ' + r };
101
+ }
102
+
103
+ if (action === 'mode' || action === '档位') {
104
+ const child = String(args?.child_session_id ?? '').trim();
105
+ if (!child) return { ok: false, msg: 'mode 需要 child_session_id(先用 list 看)' };
106
+ // 已挂的直接改;没挂但库里能按短 id 找到也认
107
+ const hit = lib.listWatching().find((x) => x.childSessionId === child || x.shortId === child.slice(0, 8));
108
+ const target = hit ? hit.childSessionId : child;
109
+ const r = await lib.setMode(target, normMode(args?.mode));
110
+ return { ok: !String(r).includes('没找到'), msg: `mode → ${r}` };
111
+ }
112
+
113
+ if (action === 'detach' || action === '取消') {
114
+ const child = String(args?.child_session_id ?? '').trim();
115
+ if (!child) return { ok: false, msg: 'detach 需要 child_session_id' };
116
+ const hit = lib.listWatching().find((x) => x.childSessionId === child || x.shortId === child.slice(0, 8));
117
+ const r = await lib.unregister(hit ? hit.childSessionId : child);
118
+ return { ok: true, msg: r + `(${String(child).slice(0, 14)}…;钩子本身是全局一个,这里只是不再为它回灌/转发)` };
119
+ }
120
+
121
+ // list
122
+ const items = lib.listWatching();
123
+ if (!items.length) {
124
+ return {
125
+ ok: true,
126
+ msg: '【回合结束回灌】当前没有盯着任何子会话。\n用 session_create 建会话时会自动挂(可带 mode);也可以用 attach 给已存在的子会话补挂。',
127
+ };
128
+ }
129
+ const lines = items.map(
130
+ (x) =>
131
+ ` · 子 ${x.shortId ?? x.childSessionId.slice(0, 8)}… → 父 ${x.parentShortId ?? x.parentAgentId.slice(0, 8)}…` +
132
+ `${x.label ? ' [' + x.label + ']' : ''}\n` +
133
+ ` 档位=${modeText(x.mode)} QQ转发=${x.qqReady ? '就绪' : '未就绪(没拿到 sender/replyTarget)'}` +
134
+ `${x.peerName ? ' → ' + x.peerName : ''}\n` +
135
+ ` 已结算=${x.turnsSeen} 次 上次回灌=${fmt(x.lastFiredAt)} 最近被引用=${fmt(x.quotedAt)}`,
136
+ );
137
+ return { ok: true, msg: `【回合结束回灌】正在盯 ${items.length} 个子会话:\n` + lines.join('\n') };
138
+ },
139
+ };
@@ -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 最长的那个工作区(否则父目录会把子目录工作区的会话一起列走)
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
+ };