@zaofan/dsh-qqbot 1.8.4 → 1.8.8
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/README.md +1 -1
- package/dist/channel-tools.js +1 -1
- package/dist/channel-tools.js.map +1 -1
- package/dist/commands/misc.d.ts +1 -1
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +2 -2
- package/dist/commands/misc.js.map +1 -1
- package/dist/features/ext-reload.d.ts +38 -0
- package/dist/features/ext-reload.d.ts.map +1 -0
- package/dist/features/ext-reload.js +30 -0
- package/dist/features/ext-reload.js.map +1 -0
- package/dist/features/extension-store.d.ts.map +1 -1
- package/dist/features/extension-store.js +46 -16
- package/dist/features/extension-store.js.map +1 -1
- package/dist/features/ns-guard.d.ts +3 -1
- package/dist/features/ns-guard.d.ts.map +1 -1
- package/dist/features/ns-guard.js +21 -8
- package/dist/features/ns-guard.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts +14 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +17 -2
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +36 -12
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +55 -10
- package/dist/index.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +6 -2
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/surface-guard.d.ts +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/utils.d.ts +34 -0
- package/dist/shared/utils.d.ts.map +1 -1
- package/dist/shared/utils.js +53 -0
- package/dist/shared/utils.js.map +1 -1
- package/docs/USER-GUIDE.md +1 -1
- package/examples/ext-tools/README.md +22 -0
- package/examples/ext-tools/lib/preset_resolve.mjs +85 -0
- package/examples/ext-tools/lib/session_watch_lib.mjs +350 -0
- package/examples/ext-tools/session_create.mjs +201 -0
- package/examples/ext-tools/session_send.mjs +141 -0
- package/examples/ext-tools/session_watch.mjs +93 -0
- package/examples/ext-tools/sessions_list.mjs +180 -0
- package/package.json +1 -1
- package/settings-host.js +22 -5
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session_send.mjs — 跨会话/跨工作区投递·**发送工具**(照抄 qqbot 插件的**入站模式**)
|
|
3
|
+
*
|
|
4
|
+
* ── 定案(主人 2026-10-12)────────────────────────────────────────────
|
|
5
|
+
* · 一律**模拟用户消息**(`source.kind='user'`)。
|
|
6
|
+
* · **不用 `session.append`** —— 它的消息没被任何回合 claim,前端会当成
|
|
7
|
+
* "待发送输入"挂到**输入框**上方。改用 `agent.inject`。
|
|
8
|
+
* · **忙时插话、空闲唤醒**(原话见 qqbot `dist/features/qq-user-questions.js:646`):
|
|
9
|
+
* 忙(回合进行中) → `agent.inject` = 宿主 `send(msg,'next-step',wakeup:false)`
|
|
10
|
+
* —— 进当前回合的下一个轮次边界,她本轮就读到;**不排下一回合队列**。
|
|
11
|
+
* 空闲 → `agent.followup` 唤醒它接着处理。
|
|
12
|
+
*
|
|
13
|
+
* ── 两种调用意图 ──────────────────────────────────────────────────────
|
|
14
|
+
* mode='append'(默认,不唤醒)→ 一律 `inject`(插话通道,静默进上下文)
|
|
15
|
+
* mode='wake' (要它立刻处理)→ 忙 `inject` / 空闲 `followup`
|
|
16
|
+
*
|
|
17
|
+
* ── 保留的两个工程细节 ────────────────────────────────────────────────
|
|
18
|
+
* ① 先拿 live agent(`agents.get` → 没有就 `agents.resume({resumeSessionId})`)
|
|
19
|
+
* ② 投递后自检:看该 agent 的 inbox 队列长度有没有变化,如实回报
|
|
20
|
+
*/
|
|
21
|
+
export const __TOOL_VERSION__ = 'send-v8-20261012-忙插话/闲唤醒';
|
|
22
|
+
|
|
23
|
+
export const isInjectableMessage = (m) => {
|
|
24
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) return false;
|
|
25
|
+
if (typeof m.role !== 'string' || !m.role) return false;
|
|
26
|
+
if (!Array.isArray(m.content) || m.content.length === 0) return false;
|
|
27
|
+
const s = m.source;
|
|
28
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return false;
|
|
29
|
+
return typeof s.kind === 'string' && !!s.kind && s.kind !== 'plugin';
|
|
30
|
+
};
|
|
31
|
+
const short = (id) => { const s = String(id ?? ''); return s.startsWith('session-') ? s.slice(8, 16) : s.slice(0, 8); };
|
|
32
|
+
const rid = (p) => (globalThis.crypto?.randomUUID?.() ?? (p + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)));
|
|
33
|
+
/** 队列长度(inject 的自检依据) */
|
|
34
|
+
function queueLen(agent) {
|
|
35
|
+
try {
|
|
36
|
+
const q = agent?.inbox;
|
|
37
|
+
const a = Array.isArray(q?.nextStep) ? q.nextStep.length : 0;
|
|
38
|
+
const b = Array.isArray(q?.nextTurn) ? q.nextTurn.length : 0;
|
|
39
|
+
return a + b;
|
|
40
|
+
} catch { return -1; }
|
|
41
|
+
}
|
|
42
|
+
function isBusy(agent) {
|
|
43
|
+
try {
|
|
44
|
+
const st = String(agent?.status ?? agent?.phase ?? '');
|
|
45
|
+
return st !== '' && st !== 'idle';
|
|
46
|
+
} catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
name: 'session_send',
|
|
51
|
+
expose: 'all',
|
|
52
|
+
description:
|
|
53
|
+
'跨会话/跨工作区投递(**模拟用户消息**,走插件同款入站模式:忙插话 / 闲唤醒)。目标用 session_id,不填=创建自己的那个会话。' +
|
|
54
|
+
'mode=append(默认)用 inject 静默进对方上下文、不唤醒;mode=wake 忙时 inject 插话、空闲 followup 唤醒。' +
|
|
55
|
+
'目标非 live 会先 resume;投递后自检队列并如实回报。',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
text: { type: 'string', description: '要投递的正文(必填)' },
|
|
58
|
+
session_id: { type: 'string', description: '目标会话 id(完整);不填=创建自己的那个会话' },
|
|
59
|
+
mode: { type: 'string', description: 'append=inject 静默进上下文不唤醒(默认) / wake=忙插话·闲唤醒' },
|
|
60
|
+
notify: { type: 'boolean', description: 'true=正文前加一行「【来自会话 <我的完整 session id>】」(默认 true;对方据此可直接回投)' },
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async run(args = {}, env = {}) {
|
|
64
|
+
const text = String(args?.text ?? '').trim();
|
|
65
|
+
if (!text) return { ok: false, msg: '缺少 text' };
|
|
66
|
+
|
|
67
|
+
const ctx = env?.ctx ?? null;
|
|
68
|
+
const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
|
|
69
|
+
if (!root?.get) return { ok: false, msg: "拿不到宿主 ctx(需要 expose:'all')" };
|
|
70
|
+
const svc = (n) => { try { return root.get(n); } catch { return undefined; } };
|
|
71
|
+
const agents = svc('agents'); const sessions = svc('sessions'); const sc = svc('sessionController');
|
|
72
|
+
if (!agents || typeof agents.get !== 'function') return { ok: false, msg: '宿主没挂 agents 服务' };
|
|
73
|
+
|
|
74
|
+
const me = String(env?.exec?.agent?.id ?? env?.exec?.sessionId ?? '').trim();
|
|
75
|
+
const mode = String(args?.mode ?? 'append').trim() === 'wake' ? 'wake' : 'append';
|
|
76
|
+
const out = [];
|
|
77
|
+
|
|
78
|
+
let targetId = String(args?.session_id ?? '').trim();
|
|
79
|
+
let srcNote = '显式指定';
|
|
80
|
+
if (!targetId) {
|
|
81
|
+
if (!me) return { ok: false, msg: '既没给 session_id,也拿不到"我是谁"' };
|
|
82
|
+
srcNote = '创建者(parentSession)';
|
|
83
|
+
try { targetId = String(sessions?.get?.(me)?.header?.parentSession ?? '').trim(); } catch { /* ignore */ }
|
|
84
|
+
if (!targetId) return { ok: false, msg: '本会话没有创建者 → 请显式给 session_id' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ★ 主人 2026-10-12 定:模拟用户消息的**首行直接给出源会话 id**(完整 id,不是短号)——
|
|
88
|
+
// 对方据此就能回投给我,不用再靠血缘字段去猜。
|
|
89
|
+
const body = (args?.notify === false || !me) ? text : '【来自会话 ' + me + '】\n' + text;
|
|
90
|
+
const msg = { id: rid('snd'), role: 'user', content: [{ type: 'text', text: body }], source: { kind: 'user' } };
|
|
91
|
+
if (!isInjectableMessage(msg)) return { ok: false, msg: '内部错误:消息形状自检不过(已中止)' };
|
|
92
|
+
|
|
93
|
+
// ① live agent(没有就 resume)
|
|
94
|
+
let agent = null, resumed = false;
|
|
95
|
+
try { agent = agents.get(targetId) ?? null; } catch { /* ignore */ }
|
|
96
|
+
if (!agent && typeof agents.resume === 'function') {
|
|
97
|
+
try { const h = await agents.resume({ resumeSessionId: targetId }); agent = h?.agent ?? agents.get?.(targetId) ?? null; resumed = !!agent; }
|
|
98
|
+
catch (e) { out.push('resume 失败: ' + String(e?.message ?? e).slice(0, 110)); }
|
|
99
|
+
}
|
|
100
|
+
if (!agent) return { ok: false, msg: `投递失败(${short(targetId)}…):目标不是 live 且 resume 没成功\n` + out.join('\n') };
|
|
101
|
+
|
|
102
|
+
// ② 忙闲 → 选通道(照抄插件"忙时插话、空闲唤醒")
|
|
103
|
+
const busy = isBusy(agent);
|
|
104
|
+
const before = queueLen(agent);
|
|
105
|
+
let via = ''; const errs = [];
|
|
106
|
+
const doInject = async (why) => { await agent.inject(msg); via = `agent.inject(${why})`; };
|
|
107
|
+
const doFollow = async (why) => { await agent.followup(msg); via = `agent.followup(${why})`; };
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (mode === 'append') {
|
|
111
|
+
await doInject('不唤醒·插话通道');
|
|
112
|
+
} else if (busy) {
|
|
113
|
+
await doInject('忙 → 插话,不排下一回合');
|
|
114
|
+
} else {
|
|
115
|
+
try { await doFollow('空闲 → 唤醒'); }
|
|
116
|
+
catch (e) { errs.push('followup→' + String(e?.message ?? e).slice(0, 80)); await doInject('followup 失败 → 回落插话'); }
|
|
117
|
+
}
|
|
118
|
+
} catch (e) { errs.push('投递→' + String(e?.message ?? e).slice(0, 100)); }
|
|
119
|
+
|
|
120
|
+
// ③ 兜底 sessionController.prompt
|
|
121
|
+
if (!via && sc && typeof sc.prompt === 'function') {
|
|
122
|
+
const req = { requestId: rid('req'), sessionId: targetId, mode: 'queue', content: [{ type: 'text', text: body }] };
|
|
123
|
+
const sig = new AbortController().signal;
|
|
124
|
+
for (const [lb, fn] of [['sc.prompt(req,sig)', () => sc.prompt(req, sig)], ['sc.prompt(req)', () => sc.prompt(req)]]) {
|
|
125
|
+
try { await fn(); via = lb + '(兜底,会开队列回合)'; break; } catch (e) { errs.push(lb + '→' + String(e?.message ?? e).slice(0, 80)); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!via) return { ok: false, msg: `投递失败(${short(targetId)}…)\n` + (out.join('\n') + '\n') + '尝试明细: ' + errs.join(' | ') };
|
|
130
|
+
|
|
131
|
+
const after = queueLen(agent);
|
|
132
|
+
const landed = before >= 0 && after >= 0 ? after > before : null;
|
|
133
|
+
out.push(`队列自检: ${before} → ${after}` + (landed === true ? ' ✅ 已入队' : landed === false ? ' ⚠️ 队列数未变(可能被立即消费,也可能没进去)' : ' ? 无法判定'));
|
|
134
|
+
out.push(`目标忙闲: ${busy ? '忙(回合进行中)' : '空闲'}`);
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
msg: `✅ 已投递到 ${short(targetId)}…(${srcNote};用户消息;方式 ${via};${resumed ? '已 resume;' : ''}` +
|
|
138
|
+
`${mode === 'wake' ? '意图=唤醒' : '意图=静默'})` + (out.length ? '\n' + out.join('\n') : ''),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +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
|
+
};
|
package/package.json
CHANGED
package/settings-host.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 仅 web profile 装配; 同源 fence 抄 modsearch。
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, renameSync, openSync, readSync, closeSync, createWriteStream } from 'node:fs';
|
|
7
|
-
import { extname, join, resolve, dirname, normalize, sep } from 'node:path';
|
|
7
|
+
import { basename, extname, join, resolve, dirname, normalize, sep } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
@@ -225,10 +225,25 @@ const OWN_SETTINGS_DIR = join(DSH_HOME_DIR, 'qqbot-settings');
|
|
|
225
225
|
* 新布局 `{DSH_HOME}/qqbot-settings/<profile>/<ns>.json` —— 各 profile 各一份,**同一个 ns 也不再互相覆盖**;
|
|
226
226
|
* 老布局 `{DSH_HOME}/qqbot-settings/<ns>.json` —— 所有 profile 共用(就是本次踩坑的根源),仅作读取兜底。
|
|
227
227
|
* 读:新路径优先 → 老路径兜底(老数据不丢);写:一律写新路径 —— 读取已兜底到老文件,首次保存天然是
|
|
228
|
-
* "老内容 + 本次改动",等于**自动搬家**。
|
|
229
|
-
*
|
|
228
|
+
* "老内容 + 本次改动",等于**自动搬家**。
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ profile 名(2026-10-12 修):**不能只读 `process.env.DSH_PROFILE`**。宿主不会把 profile 名放进
|
|
231
|
+
* 自己的环境变量 —— DSH 是通过 `ctx.get('profileContext').name` 提供的,`DSH_PROFILE` 只在
|
|
232
|
+
* **每次 shell / 工具调用**时由 harness 临时注入(见 `@deepseek-ai/dsh-shell-env`)。于是这里永远拿到空
|
|
233
|
+
* → 永远落 `_default/`,"各 profile 各一份"形同虚设(实测 `writer.json` 记的是 `"profile": "unknown"`)。
|
|
234
|
+
* 现在与 `src/shared/utils.ts` 的 `resolveProfileName()` **同规则**:环境变量 → `PROFILE_ROOT` 目录名
|
|
235
|
+
* (要求其父目录名为 `profiles`,挡住源码 checkout 目录)→ 落 `_default`。改一处务必同步另一处。
|
|
230
236
|
*/
|
|
231
|
-
|
|
237
|
+
function resolveProfileName() {
|
|
238
|
+
const env = String(process.env.DSH_PROFILE || '').trim();
|
|
239
|
+
if (env) return env;
|
|
240
|
+
try {
|
|
241
|
+
return basename(dirname(PROFILE_ROOT)) === 'profiles' ? basename(PROFILE_ROOT) : '';
|
|
242
|
+
} catch {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const NS_PROFILE_DIR = String(resolveProfileName()).trim().replace(/[^A-Za-z0-9_-]/g, '_') || '_default';
|
|
232
247
|
function safeNsOf(ns) {
|
|
233
248
|
return String(ns || NS).replace(/[^A-Za-z0-9_-]/g, '_') || 'im-qqbot';
|
|
234
249
|
}
|
|
@@ -274,7 +289,9 @@ function readNsWriter(ns) {
|
|
|
274
289
|
} catch { return null; }
|
|
275
290
|
}
|
|
276
291
|
function myProfileName() {
|
|
277
|
-
|
|
292
|
+
// 2026-10-12:改用 resolveProfileName()(同规则见其定义处注释)——
|
|
293
|
+
// 原来只读 process.env.DSH_PROFILE,宿主里永远是空 → 指纹恒为 "unknown" → NS_CONFLICT 拦截永久失效。
|
|
294
|
+
return resolveProfileName() || 'unknown';
|
|
278
295
|
}
|
|
279
296
|
/** 该设置槽是否正被**别的** profile 占用(供 /read 与 /update 共用) */
|
|
280
297
|
function nsConflictOf(ns) {
|