@zaofan/dsh-qqbot 1.10.2 → 1.10.4

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.
@@ -56,13 +56,13 @@
56
56
  * 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
57
57
  * 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
58
58
  */
59
- export const __LIB_VERSION__ = 'lib-v12-20261012-归属行带会话名/预设/工作区';
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.10.2",
3
+ "version": "1.10.4",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",