@zaofan/dsh-qqbot 1.8.6 → 1.9.2
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 +114 -27
- package/README_EN.md +122 -8
- package/dist/features/ext-capabilities.d.ts +85 -0
- package/dist/features/ext-capabilities.d.ts.map +1 -1
- package/dist/features/ext-capabilities.js +4 -0
- package/dist/features/ext-capabilities.js.map +1 -1
- package/dist/features/ext-hooks.d.ts +84 -0
- package/dist/features/ext-hooks.d.ts.map +1 -0
- package/dist/features/ext-hooks.js +299 -0
- package/dist/features/ext-hooks.js.map +1 -0
- package/dist/features/ext-inbound-context.d.ts +202 -0
- package/dist/features/ext-inbound-context.d.ts.map +1 -0
- package/dist/features/ext-inbound-context.js +724 -0
- package/dist/features/ext-inbound-context.js.map +1 -0
- package/dist/features/ext-inbound-state.d.ts +153 -0
- package/dist/features/ext-inbound-state.d.ts.map +1 -0
- package/dist/features/ext-inbound-state.js +419 -0
- package/dist/features/ext-inbound-state.js.map +1 -0
- package/dist/features/ext-inbound.d.ts +117 -0
- package/dist/features/ext-inbound.d.ts.map +1 -0
- package/dist/features/ext-inbound.js +518 -0
- package/dist/features/ext-inbound.js.map +1 -0
- package/dist/features/ext-reload.d.ts +22 -0
- package/dist/features/ext-reload.d.ts.map +1 -1
- package/dist/features/ext-reload.js +32 -0
- package/dist/features/ext-reload.js.map +1 -1
- package/dist/features/extension-store.d.ts +13 -0
- package/dist/features/extension-store.d.ts.map +1 -1
- package/dist/features/extension-store.js +76 -18
- package/dist/features/extension-store.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 +61 -2
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/debounce.d.ts +43 -1
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +71 -35
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +88 -1
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -9
- package/dist/index.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +7 -3
- 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 +40 -0
- package/dist/shared/utils.d.ts.map +1 -1
- package/dist/shared/utils.js +59 -2
- package/dist/shared/utils.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +23 -3
- package/dist/transport/inbound.js.map +1 -1
- package/docs/USER-GUIDE.md +93 -36
- package/docs/botplay-contract.md +2 -0
- package/examples/ext-hooks/README.md +95 -0
- package/examples/ext-hooks/hello-hook.mjs +54 -0
- package/examples/ext-hooks/session-watch-hook.mjs +119 -0
- package/examples/ext-inbound/README.md +230 -0
- package/examples/ext-inbound/class-advisor.mjs +306 -0
- package/examples/ext-inbound/no-wake-on-child-quote.mjs +192 -0
- 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 +141 -141
- package/settings-host.js +22 -5
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session_watch_lib.mjs — 「回合结束回灌」的共用实现(放在 tools/lib/ 下,不被当工具扫描)
|
|
3
|
+
*
|
|
4
|
+
* 口径**照抄 qqbot 的 passive 收尾**(src/transport/outbound.ts:80-211):
|
|
5
|
+
* · 回合内累积 `assistant/message` 的正文(= 它的 noteGenerated(sessionKey, msgId, text))
|
|
6
|
+
* · `turn/end` 时结算(它的注释原文:「assistant/message 用完即清,turn/end 兜底清」)
|
|
7
|
+
* · 取**最后一个正文块**的文本(它收尾时也是"把最后一块复制一份")
|
|
8
|
+
*
|
|
9
|
+
* 本库只做两件事:挂监听、把末段正文回灌给创建者(父 agent)。
|
|
10
|
+
*
|
|
11
|
+
* ★ 2026-10-12 定案(主人):回灌**模拟用户消息**(source.kind='user')+ **唤醒**父会话。
|
|
12
|
+
* `followup` 就是 `inject` 的唤醒档(inject=next-step 不唤醒;followup=next-turn 唤醒),
|
|
13
|
+
* 所以这里用 followup 即"注入 + 唤醒"。下面的历史分析仍保留,供追溯为什么不再走通知身份。
|
|
14
|
+
* 原因(实测结论):`inject` 落 `agent/inbox/spliced **target=next-step**` —— 排队到下次组包、
|
|
15
|
+
* **不产生回合**,因此既没有 turn 触发节点、**UI 上也什么都没有**。
|
|
16
|
+
* 而 `followup` 落 `target=next-turn` ⇒ 产生回合 ⇒ 被渲染成 `TurnTriggerNodeView`:
|
|
17
|
+
* 那个「白框」。回灌消息的 `source.kind='subagent-settled'` 决定白框标题
|
|
18
|
+
* (dsh-client-ui-chat 的 turnTriggerDetails:subagent-settled → 「子任务状态更新」)。
|
|
19
|
+
* 代价:回灌会**唤醒父会话跑一轮**(烧 token),故正文里加了一行"无需回复"提示。
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ 回灌消息的形状**必须**对齐宿主「子会话结算通知」(createSettlementMessage):
|
|
22
|
+
* { id, role:'user', content:[摘要行, 结尾正文引导语, ...正文块],
|
|
23
|
+
* source:{ kind:'subagent-settled', form:'notice', summary, senderSessionId } }
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 注入前必须过 isInjectableMessage() 自检(fail-closed)。宿主的顺序是
|
|
26
|
+
* **先 append 落盘、后读 `msg.source.kind`**:形状不合法时坏记录已经写进
|
|
27
|
+
* `agent/inbox/spliced.data.inserted[]`,之后投影抛错 —— 会话文件从此无法加载。
|
|
28
|
+
* 事故实例:会话 9643a885 的 seq 4222 曾被写成 `inserted: ["<字符串>"]`。
|
|
29
|
+
*
|
|
30
|
+
* 用法:
|
|
31
|
+
* import { register } from './lib/session_watch_lib.mjs';
|
|
32
|
+
* await register(root, { parentAgentId, childSessionId, label });
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
|
|
36
|
+
* 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
|
|
37
|
+
*/
|
|
38
|
+
export const __LIB_VERSION__ = 'lib-v10-20261012-首行给源会话id';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 盯梢名单。**放在 globalThis 上**,不是模块局部变量。
|
|
42
|
+
*
|
|
43
|
+
* 原因:`tools/lib/` 的 import 带了 `?v=<mtime>` 版本号(为让热刷生效),
|
|
44
|
+
* 于是每改一次本文件就会产生一个**新的模块实例**。若名单是模块局部的,
|
|
45
|
+
* 改一次 lib 就把已登记的盯梢对象全丢了。放 globalThis 上跨实例共享。
|
|
46
|
+
*/
|
|
47
|
+
const WATCHING_KEY = Symbol.for('dsh-qqbot.session-watch.watching');
|
|
48
|
+
const watching = (globalThis[WATCHING_KEY] ??= new Map()); // childSessionId -> { parentAgentId, label, lastText, seen, firedAt }
|
|
49
|
+
|
|
50
|
+
/** 回灌钩子总开关。false = 正常挂 `session/event` 监听。 */
|
|
51
|
+
const HOOK_DISABLED = false;
|
|
52
|
+
|
|
53
|
+
/** 监听只装一次(跨模块实例去重);handler 指针让新实例能立刻接管旧监听。 */
|
|
54
|
+
const HOOK_KEY = Symbol.for('dsh-qqbot.session-watch.hook');
|
|
55
|
+
const HANDLER_KEY = Symbol.for('dsh-qqbot.session-watch.handler');
|
|
56
|
+
let hooked = false;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* turn/end 的 data.reason → 人话。
|
|
60
|
+
* 口径来自 webui 的两条文案(dsh-client-ui-chat/lib/client.js):
|
|
61
|
+
* "message.turnProcess.worked" = 已完成 ← 正常跑完
|
|
62
|
+
* "message.stopped" = 已停止 ← 被停止/中断
|
|
63
|
+
* 宿主在 index.js:912 把 event.data.reason 记进 inflight.endReason,webui 据此显示。
|
|
64
|
+
*/
|
|
65
|
+
function reasonText(reason) {
|
|
66
|
+
const r = String(reason ?? '').toLowerCase();
|
|
67
|
+
if (!r) return '结束';
|
|
68
|
+
if (/stop|abort|cancel|interrupt|halt/.test(r)) return '已停止';
|
|
69
|
+
if (/complet|done|finish|success|ok/.test(r)) return '已完成';
|
|
70
|
+
if (/error|fail/.test(r)) return '出错';
|
|
71
|
+
return r; // 未知取值就原样带出来,便于以后补映射
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `notice` 摘要行的长度上限,与宿主 `dsh-llm` 的 CONTEXT_SUMMARY_MAX_CHARS 保持一致。
|
|
76
|
+
* 宿主原文注释:producer 的一行说明会写进持久日志,所以要限长。
|
|
77
|
+
*/
|
|
78
|
+
const CONTEXT_SUMMARY_MAX_CHARS = 120;
|
|
79
|
+
|
|
80
|
+
/** 把摘要行压到上限内(超出用省略号),口径照抄宿主 dsh-llm 的 boundContextSummary()。 */
|
|
81
|
+
function boundContextSummary(summary) {
|
|
82
|
+
const t = String(summary ?? '');
|
|
83
|
+
return t.length <= CONTEXT_SUMMARY_MAX_CHARS ? t : `${t.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 取会话短 id 用于显示。
|
|
88
|
+
* ⚠️ 子会话 id 形如 `session-f147236a-…`,直接 slice(0,8) 只会得到无意义的 `session-`。
|
|
89
|
+
* 所以先剥掉 `session-` 前缀再截断。
|
|
90
|
+
*/
|
|
91
|
+
export function shortSessionId(id) {
|
|
92
|
+
const s = String(id ?? '');
|
|
93
|
+
return (s.startsWith('session-') ? s.slice(8) : s).slice(0, 8);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 把 `turn/end` 的 reason 归一成字符串。
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ 2026-10-12 实测:`event.data.reason` **不是字符串而是对象**,形如
|
|
100
|
+
* `{kind:'completed'}` 或 `{kind:'aborted', reason:{kind:'user'}}`。
|
|
101
|
+
* 旧代码直接 `String(reason)` ⇒ 摘要行里出现 `回合[object object](reason=[object Object])`。
|
|
102
|
+
* 本函数优先取最外层的 `kind`,没有就下钻内层 `reason`,都不是才退回 String()。
|
|
103
|
+
*/
|
|
104
|
+
export function reasonKind(value) {
|
|
105
|
+
if (value === null || value === undefined) return '';
|
|
106
|
+
if (typeof value === 'string') return value;
|
|
107
|
+
if (typeof value === 'object') {
|
|
108
|
+
if (typeof value.kind === 'string' && value.kind) return value.kind;
|
|
109
|
+
if (value.reason !== undefined && value.reason !== value) return reasonKind(value.reason);
|
|
110
|
+
if (typeof value.status === 'string' && value.status) return value.status;
|
|
111
|
+
if (typeof value.stopReason === 'string' && value.stopReason) return value.stopReason;
|
|
112
|
+
}
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 把各种可能的 content 形状压成纯文本。
|
|
118
|
+
*
|
|
119
|
+
* ★ 2026-10-12 修(主人实测):**只取 `type === 'text'` 的正文块**。
|
|
120
|
+
* 早先的过滤条件是 `x.type === 'text' || typeof x.text === 'string'` ——
|
|
121
|
+
* 而 **reasoning(思考)块同样带 `.text` 字符串**,于是思考被一起并进来,
|
|
122
|
+
* 回灌的"结尾正文"里冒出一整段英文思考(子会话 3bee83ac 那次实测)。
|
|
123
|
+
* reasoning / tool-call 等块一律丢弃。
|
|
124
|
+
*/
|
|
125
|
+
function asText(c) {
|
|
126
|
+
if (typeof c === 'string') return c.trim();
|
|
127
|
+
if (Array.isArray(c)) {
|
|
128
|
+
return c
|
|
129
|
+
.filter((x) => x && x.type === 'text' && typeof x.text === 'string')
|
|
130
|
+
.map((x) => String(x.text))
|
|
131
|
+
.join('\n')
|
|
132
|
+
.trim();
|
|
133
|
+
}
|
|
134
|
+
if (c && typeof c === 'object') {
|
|
135
|
+
if (c.type === 'reasoning') return ''; // 思考块不算正文
|
|
136
|
+
if (typeof c.text === 'string') return String(c.text).trim();
|
|
137
|
+
if (Array.isArray(c.blocks)) return asText(c.blocks); // 有些版本正文藏在 blocks 里
|
|
138
|
+
}
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 从事件里尽力抽正文(assistant/message 的形状随版本变,逐层试) */
|
|
143
|
+
function textOf(ev) {
|
|
144
|
+
const cands = [
|
|
145
|
+
ev?.data?.message?.content,
|
|
146
|
+
ev?.data?.content,
|
|
147
|
+
ev?.message?.content,
|
|
148
|
+
ev?.data?.text,
|
|
149
|
+
ev?.text,
|
|
150
|
+
ev?.data?.message?.text,
|
|
151
|
+
];
|
|
152
|
+
for (const c of cands) {
|
|
153
|
+
const t = asText(c);
|
|
154
|
+
if (t) return t;
|
|
155
|
+
}
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 兜底:直接从会话实例取"最后一条助手消息"的正文 */
|
|
160
|
+
function lastAssistantText(root, sid) {
|
|
161
|
+
try {
|
|
162
|
+
const sess = root.get('sessions')?.get?.(sid);
|
|
163
|
+
const msgs = sess?.deriveMessages?.() ?? [];
|
|
164
|
+
for (let i = msgs.length - 1; i >= 0; i -= 1) {
|
|
165
|
+
const m = msgs[i];
|
|
166
|
+
const role = String(m?.role ?? m?.type ?? '');
|
|
167
|
+
if (role.includes('assistant')) {
|
|
168
|
+
const t = asText(m?.content);
|
|
169
|
+
if (t) return t;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch { /* ignore */ }
|
|
173
|
+
return '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 注入前的形状自检(fail-closed)。
|
|
178
|
+
*
|
|
179
|
+
* 复刻宿主 v4 的准入条件(dsh-session-persistence-jsonl/lib/worker.cjs):
|
|
180
|
+
* ① `agent/inbox/spliced` 的 `inserted[]` 每项必须 `isSessionFormatJsonObject`;
|
|
181
|
+
* ② 每项的 `source` 必须是对象、`kind` 为非空字符串、且 !== 'plugin'
|
|
182
|
+
* (源码原文:`format v4 message requires a producer-owned source kind`)。
|
|
183
|
+
*
|
|
184
|
+
* 为什么必须自检而不是交给 inject():宿主是**先 append 落盘、后读 source.kind** 的顺序。
|
|
185
|
+
* 传进非对象时,坏记录已经写进 `inserted[]`,之后投影抛错 —— 文件从此无法加载。
|
|
186
|
+
* 事故实例:会话 9643a885 的 seq 4222 被写成 `inserted: ["<字符串>"]`,整个会话读不出来。
|
|
187
|
+
*/
|
|
188
|
+
export function isInjectableMessage(m) {
|
|
189
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) return false;
|
|
190
|
+
if (typeof m.role !== 'string' || !m.role) return false;
|
|
191
|
+
if (!Array.isArray(m.content) || m.content.length === 0) return false;
|
|
192
|
+
const src = m.source;
|
|
193
|
+
if (!src || typeof src !== 'object' || Array.isArray(src)) return false;
|
|
194
|
+
if (typeof src.kind !== 'string' || !src.kind || src.kind === 'plugin') return false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 构造一条「子会话结算通知」形状的回灌消息(纯函数,便于单测)。
|
|
200
|
+
*
|
|
201
|
+
* 形状**对齐宿主** dsh-subagent/lib/index.js 的 `createSettlementMessage()`:
|
|
202
|
+
* source : { kind:'subagent-settled', form:'notice', summary, senderSessionId }
|
|
203
|
+
* content: [ 摘要行, 结尾正文引导语, ...正文块 ],role 由 createUserMessage 固定为 'user'
|
|
204
|
+
*
|
|
205
|
+
* 采用宿主形状的三个理由:
|
|
206
|
+
* ① kind 是宿主自有的 producer-owned 值,不再冒充真人 user 发言;
|
|
207
|
+
* ② senderSessionId 明确归属到具体子会话,UI 侧归进**子任务**那条通道而不是混进
|
|
208
|
+
* 用户真实发言流里 —— 这与上一轮「回灌插错位置」是同一类隐患;
|
|
209
|
+
* ⚠️ 更正:dsh-client-ui-chat 的 turnTriggerDetails 里 `subagent-settled` 对应的是
|
|
210
|
+
* 「**子任务状态更新**」(icon subagent);「收到任务消息」是 `agent-message`。本条注释早先写错;
|
|
211
|
+
* ③ summary 与宿主同口径(dsh-llm CONTEXT_SUMMARY_MAX_CHARS = 120)。
|
|
212
|
+
*
|
|
213
|
+
* @param {{sessionId?: string, label?: string, rawReason?: string, text?: string}} input
|
|
214
|
+
* @returns 可直接交给 agent.inject() 的完整消息对象
|
|
215
|
+
*/
|
|
216
|
+
export function buildSettlementNotice(input = {}) {
|
|
217
|
+
const sid = String(input.sessionId ?? '');
|
|
218
|
+
const label = String(input.label ?? '');
|
|
219
|
+
const rawReason = String(input.rawReason ?? '');
|
|
220
|
+
const stage = reasonText(rawReason);
|
|
221
|
+
const summary = boundContextSummary(
|
|
222
|
+
`子会话${label ? '「' + label + '」' : ''} ${shortSessionId(sid)}… 回合${stage}` +
|
|
223
|
+
`${rawReason && rawReason !== stage ? '(reason=' + rawReason + ')' : ''}`,
|
|
224
|
+
);
|
|
225
|
+
return {
|
|
226
|
+
id: (globalThis.crypto?.randomUUID?.() ?? ('inj-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8))),
|
|
227
|
+
role: 'user',
|
|
228
|
+
content: [
|
|
229
|
+
// ★ 主人 2026-10-12 定:模拟用户消息的**首行直接给出源会话 id**(完整 id)。
|
|
230
|
+
{ type: 'text', text: '【来自会话 ' + sid + '】' },
|
|
231
|
+
{ type: 'text', text: summary },
|
|
232
|
+
{ type: 'text', text: '它的结尾正文:' },
|
|
233
|
+
{ type: 'text', text: String(input.text ?? '') },
|
|
234
|
+
{ type: 'text', text: '(这是子会话的自动回灌,父会话无需回复。)' },
|
|
235
|
+
],
|
|
236
|
+
// ★ 2026-10-12 主人定案:回灌**一律模拟用户消息**(source.kind='user'),
|
|
237
|
+
// 不再伪装成子会话结算通知 / 系统消息 / 开发者消息。
|
|
238
|
+
// 理由:那些身份要么被宿主拒收(system/developer 需要 turn+step 且非 JSON 可序列化即被丢),
|
|
239
|
+
// 要么在 UI 上归属混乱;统一成用户消息最简单、最可预期。
|
|
240
|
+
source: { kind: 'user' },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function handler(root, session, event) {
|
|
245
|
+
try {
|
|
246
|
+
const sid = String(session?.id ?? session?.sessionId ?? '');
|
|
247
|
+
const w = sid ? watching.get(sid) : null;
|
|
248
|
+
if (!w) return;
|
|
249
|
+
const type = String(event?.type ?? '');
|
|
250
|
+
|
|
251
|
+
// 累积(对应 qqbot 的 noteGenerated)
|
|
252
|
+
if (type === 'assistant/message') {
|
|
253
|
+
const t = textOf(event);
|
|
254
|
+
if (t) w.lastText = t;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (type !== 'turn/end') return;
|
|
258
|
+
|
|
259
|
+
// 结算:优先用累积到的那一段;没有再从会话实例兜底取
|
|
260
|
+
let text = w.lastText;
|
|
261
|
+
w.lastText = ''; // ★ 取走即清空:否则"只有工具调用、没有正文"的回合会重复回灌上一回合的正文
|
|
262
|
+
if (!text) text = lastAssistantText(root, sid);
|
|
263
|
+
w.seen = (w.seen ?? 0) + 1;
|
|
264
|
+
w.lastReason = reasonKind(event?.data?.reason ?? event?.data?.status ?? event?.data?.stopReason);
|
|
265
|
+
if (!text) return; // 空回合(只有工具调用/无正文)→ 不回灌
|
|
266
|
+
// ★ 去重:兜底路径可能又取到"上一次已经灌过"的那段正文,内容相同就不再灌第二遍
|
|
267
|
+
if (text === w.lastSent) return;
|
|
268
|
+
w.lastSent = text;
|
|
269
|
+
|
|
270
|
+
const agents = root.get('agents');
|
|
271
|
+
const parent = agents?.get?.(w.parentAgentId) ?? null;
|
|
272
|
+
if (!parent) return; // 父不在(不 live)→ 静默跳过,不制造错误
|
|
273
|
+
|
|
274
|
+
const rawReason = reasonKind(event?.data?.reason ?? event?.data?.status ?? event?.data?.stopReason);
|
|
275
|
+
const candidate = buildSettlementNotice({ sessionId: sid, label: w.label, rawReason, text });
|
|
276
|
+
if (!isInjectableMessage(candidate)) return; // 自检不过 → 宁可这次不回灌
|
|
277
|
+
try {
|
|
278
|
+
// ★ 2026-10-12 定案(照抄 qqbot 插件的入站模式 —— dist/features/qq-user-questions.js:646):
|
|
279
|
+
// 「忙时插话、空闲唤醒」
|
|
280
|
+
// · 父会话**回合进行中(忙)** → `agent.inject`
|
|
281
|
+
// = 宿主 `send(msg,'next-step',wakeup:false)`:进当前回合的下一个轮次边界,
|
|
282
|
+
// 她本轮就读到了;**不排进"下一回合"队列** ⇒ **不会挂到输入框**。
|
|
283
|
+
// · 父会话**空闲** → `agent.followup` 唤醒它接着处理。
|
|
284
|
+
// ⚠️ 之前无论忙闲都用 followup → 忙时落成 `target=next-turn` 排队消息,
|
|
285
|
+
// 前端把它当"待发送输入"显示在输入框上方(主人实测)。这才是那个毛病的根因。
|
|
286
|
+
const busy = (() => {
|
|
287
|
+
try {
|
|
288
|
+
const st = String(parent.status ?? parent.phase ?? '');
|
|
289
|
+
return st !== '' && st !== 'idle';
|
|
290
|
+
} catch { return false; }
|
|
291
|
+
})();
|
|
292
|
+
if (busy) await parent.inject(candidate); // 插话(不唤醒、不排队、不进输入框)
|
|
293
|
+
else await parent.followup(candidate); // 空闲 → 唤醒
|
|
294
|
+
w.firedAt = Date.now();
|
|
295
|
+
} catch { /* 注入失败 → 静默;监听里绝不抛,免得影响宿主事件链 */ }
|
|
296
|
+
} catch { /* 监听里绝不抛,免得影响宿主事件链 */ }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function installHook(root) {
|
|
300
|
+
if (HOOK_DISABLED) return;
|
|
301
|
+
|
|
302
|
+
// ① 把「当前实例的 handler」发布到 globalThis:已装好的那个监听会转发到最新实例,
|
|
303
|
+
// 所以改完 lib 热刷后新代码立刻接管,不必重挂监听。
|
|
304
|
+
globalThis[HANDLER_KEY] = (session, event) => { void handler(root, session, event); };
|
|
305
|
+
|
|
306
|
+
// ② 监听全局只装一次。lib 带 mtime 版本号加载,改一次就换一个模块实例;
|
|
307
|
+
// 若各实例各自 root.on 一遍,同一个 turn/end 会被回灌多次。
|
|
308
|
+
if (globalThis[HOOK_KEY]) { hooked = true; return; }
|
|
309
|
+
try {
|
|
310
|
+
// 与 qqbot 的 mc_tell 同款:挂在插件根 ctx 上,事件回调 (session, event)。
|
|
311
|
+
// 注意:回调里通过 globalThis 取 handler,永远调用最新那一版。
|
|
312
|
+
root.on('session/event', (session, event) => {
|
|
313
|
+
const h = globalThis[HANDLER_KEY];
|
|
314
|
+
if (typeof h === 'function') h(session, event);
|
|
315
|
+
});
|
|
316
|
+
globalThis[HOOK_KEY] = true;
|
|
317
|
+
hooked = true;
|
|
318
|
+
} catch { hooked = false; }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 登记一个"要盯着的子会话"。
|
|
323
|
+
* @returns 人话描述(挂成功/失败原因),不抛错。
|
|
324
|
+
*/
|
|
325
|
+
export async function register(root, opts = {}) {
|
|
326
|
+
const parentAgentId = String(opts.parentAgentId ?? '').trim();
|
|
327
|
+
const childSessionId = String(opts.childSessionId ?? '').trim();
|
|
328
|
+
const label = String(opts.label ?? '').trim();
|
|
329
|
+
if (!parentAgentId || !childSessionId) return '未挂(缺 parentAgentId 或 childSessionId)';
|
|
330
|
+
if (!root || typeof root.on !== 'function') return '未挂(root 不支持 .on)';
|
|
331
|
+
installHook(root);
|
|
332
|
+
if (HOOK_DISABLED) return '未挂(回灌钩子当前被 HOOK_DISABLED 主动关闭,属预期;恢复见 installHook 注释)';
|
|
333
|
+
if (!hooked) return '未挂(root.on 抛错)';
|
|
334
|
+
watching.set(childSessionId, { parentAgentId, label, lastText: '', seen: 0, firedAt: 0 });
|
|
335
|
+
return `已挂(子=${shortSessionId(childSessionId)}… → 父=${shortSessionId(parentAgentId)}…;回合结束即回灌末段正文)`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 给别的工具(如 session_watch 状态查询)读当前盯着的清单 */
|
|
339
|
+
export function listWatching() {
|
|
340
|
+
return [...watching.entries()].map(([sid, w]) => ({
|
|
341
|
+
childSessionId: sid,
|
|
342
|
+
shortId: shortSessionId(sid),
|
|
343
|
+
parentAgentId: w.parentAgentId,
|
|
344
|
+
parentShortId: shortSessionId(w.parentAgentId),
|
|
345
|
+
label: w.label,
|
|
346
|
+
hasText: !!w.lastText,
|
|
347
|
+
turnsSeen: w.seen ?? 0,
|
|
348
|
+
lastFiredAt: w.firedAt ?? 0,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session_create.mjs — 创建**子会话**(跨工作区/跨会话),走宿主 sessionController
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 本文件的来历:前面几个版本(v1~v5)都在绕路 —— 试图用 `ctx.sessions.create()` +
|
|
5
|
+
* `ctx.agents.create()` 手工拼一个会话,结果一路撞墙(session 已存在 / agent id 不等于 session id /
|
|
6
|
+
* 选项读不到)。**权威范例是记忆插件 dsh-auto-memory**:它用宿主服务 `sessionController` 建会话,
|
|
7
|
+
* 源码原文(@a9i5k4/dsh-auto-memory/lib/index.js):
|
|
8
|
+
* created = await sc.create({
|
|
9
|
+
* ...(workspaceId ? { workspaceId } : { cwd }),
|
|
10
|
+
* ...(agentPreset ? { agentPreset } : {}),
|
|
11
|
+
* })
|
|
12
|
+
* const newId = String(created && (created.sessionId || created.id) || '')
|
|
13
|
+
* 两个坑(同文件注释):
|
|
14
|
+
* ① create() **只在传 workspaceId 时**才 attachSession;只传 cwd 仅设工作目录 → 会话落"未分组";
|
|
15
|
+
* ② 权限不在 create 里,走 `permissionPresets`(current 读 / set 写),完全权限预设名 = danger-full-access。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 主人定(2026-10-12):**派活是"父"的职责** ⇒ 本工具只在 QQ 会话注册(`expose: 'qq'`),
|
|
18
|
+
* 子会话看不到,免得无限套娃。回执通道是 `session_send`(那个对子会话开放)。
|
|
19
|
+
*/
|
|
20
|
+
import { statSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 带版本号地加载共用库 —— **必须这么加载**。
|
|
25
|
+
*
|
|
26
|
+
* 背景:插件的 extension-store 只给**工具文件本身**的 import 加了 `?v=<mtime>`
|
|
27
|
+
* 来绕过 ESM 模块缓存;工具**内部**再 import 别的文件时,裸路径会被模块缓存永久命中。
|
|
28
|
+
* 后果:改了 `tools/lib/` 下的文件,热刷(tools_reload)**不会生效**,只有重启宿主才行 ——
|
|
29
|
+
* 这曾导致"以为改好了、实际跑的还是旧代码"(2026-10-12 实测踩到)。
|
|
30
|
+
*/
|
|
31
|
+
async function loadWatchLib() {
|
|
32
|
+
const url = new URL('./lib/session_watch_lib.mjs', import.meta.url);
|
|
33
|
+
let v = '0';
|
|
34
|
+
try { v = String(statSync(fileURLToPath(url)).mtimeMs); } catch { /* 拿不到 mtime 就退回裸 import */ }
|
|
35
|
+
return import(v === '0' ? url.href : `${url.href}?v=${v}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
name: 'session_create',
|
|
40
|
+
// ★ 2026-10-12 主人定:父会话专用 ⇒ 只在 QQ 会话注册(子会话不可见)。
|
|
41
|
+
expose: 'qq',
|
|
42
|
+
description:
|
|
43
|
+
'创建对话(跨工作区/跨会话):可指定工作区(目录或标题片段,默认同工作区)、可给初始任务、默认完全权限;建好后自动给它挂"回合结束把末段正文回灌创建者"的钩子。',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
task: { type: 'string', description: '初始任务文本(会自动送进新会话;不填=只建不派活)' },
|
|
46
|
+
workspace: { type: 'string', description: '目标工作区:目录路径片段或标题片段(可选;默认同工作区)' },
|
|
47
|
+
cwd: { type: 'string', description: '工作目录(可选;等价于指定工作区)' },
|
|
48
|
+
name: { type: 'string', description: '标签/备注(可选,建好后写进会话标题)' },
|
|
49
|
+
preset: { type: 'string', description: '权限预设(默认 danger-full-access 完全权限)' },
|
|
50
|
+
agent_preset: { type: 'string', description: '人设(agent 预设):收**任何字符** —— 先按预设名(显示名)反查 id,再按 id 匹配(忽略大小写;唯一子串也认)。如「标准模式」/standard(显示名与 id 都认)。不填=继承发起方自己' },
|
|
51
|
+
wake: { type: 'boolean', description: 'true=建完立刻跑一轮(默认 false=只追加不唤醒,省 token)' },
|
|
52
|
+
watch: { type: 'boolean', description: 'true=挂"回合结束回灌"钩子(默认 true)' },
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async run(args = {}, env = {}) {
|
|
56
|
+
const ctx = env?.ctx ?? null;
|
|
57
|
+
const root = ctx && typeof ctx === 'object' && ctx.root ? ctx.root : ctx;
|
|
58
|
+
if (!root?.get) return { ok: false, msg: '拿不到宿主 ctx(需要 expose:\'all\')' };
|
|
59
|
+
|
|
60
|
+
const me = String(env?.exec?.agent?.id ?? env?.exec?.sessionId ?? '').trim();
|
|
61
|
+
const svc = (k) => { try { return root.get(k); } catch { return undefined; } };
|
|
62
|
+
const sc = svc('sessionController');
|
|
63
|
+
if (!sc || typeof sc.create !== 'function') {
|
|
64
|
+
return { ok: false, msg: '宿主没有可用的 sessionController.create()(跨会话创建就靠它;请确认 dsh web 宿主版本)' };
|
|
65
|
+
}
|
|
66
|
+
const out = [`我=${me.slice(0, 8)}… 服务: sessionController ✓`];
|
|
67
|
+
|
|
68
|
+
// ── 解析目标工作区 → workspaceId(关键:只给 cwd 会落"未分组工作区")──
|
|
69
|
+
const wantWs = String(args?.workspace ?? args?.cwd ?? '').trim();
|
|
70
|
+
let workspaceId = '';
|
|
71
|
+
let wsPath = '';
|
|
72
|
+
let myCwd = '';
|
|
73
|
+
try {
|
|
74
|
+
const reg = svc('workspaceRegistry');
|
|
75
|
+
const list = (reg?.list?.() ?? []).filter(Boolean);
|
|
76
|
+
if (me) {
|
|
77
|
+
const mine = list.find((w) => (w.sessionIds ?? []).includes(me));
|
|
78
|
+
myCwd = String(mine?.path ?? '');
|
|
79
|
+
}
|
|
80
|
+
const key = wantWs || myCwd;
|
|
81
|
+
if (key) {
|
|
82
|
+
const k = key.toLowerCase();
|
|
83
|
+
const hit = list.find((w) => String(w?.path ?? '').toLowerCase().includes(k)) ??
|
|
84
|
+
list.find((w) => String(w?.title ?? '').toLowerCase().includes(k));
|
|
85
|
+
if (hit) { workspaceId = String(hit.id ?? ''); wsPath = String(hit.path ?? ''); }
|
|
86
|
+
else if (wantWs) out.push(`⚠ 没匹配到工作区「${wantWs}」,改用 cwd 直传(会落"未分组",可能不是你要的)`);
|
|
87
|
+
}
|
|
88
|
+
out.push(`工作区: ${workspaceId ? wsPath + ' (id=' + workspaceId.slice(0, 8) + '…)' : '(未指定,继承宿主默认)'}`);
|
|
89
|
+
} catch (e) { out.push('工作区解析失败(继续): ' + String(e?.message ?? e)); }
|
|
90
|
+
|
|
91
|
+
// ── ① 建会话 ──
|
|
92
|
+
// ⚠️ 2026-10-12 实测纠错:`agentPreset` 是 **agent 预设**(standard / whale-girl / softwhale…),
|
|
93
|
+
// **不是权限预设** —— 传 'danger-full-access' 会报 `Unknown agent preset: danger-full-access`。
|
|
94
|
+
// 权限是另一条链:permissionPresets.set(session, <权限预设名>),完全权限 = 'danger-full-access'。
|
|
95
|
+
let myPreset = 'standard';
|
|
96
|
+
try {
|
|
97
|
+
const list = (await svc('sessionQuery')?.listSessions?.()) ?? [];
|
|
98
|
+
const mine = list.find((s) => String(s?.header?.id ?? s?.id) === me);
|
|
99
|
+
if (mine?.header?.agentPreset) myPreset = String(mine.header.agentPreset);
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
// ★ 2026-10-12 主人定:人设参数收**任何字符** —— 先按「预设名」反查 id,再按「id」匹配。
|
|
102
|
+
// 匹配顺序与容忍度全在 lib/preset_resolve.mjs(按 mtime 版本化加载 ⇒ 改库不必给工具改名)。
|
|
103
|
+
const wantPreset = String(args?.agent_preset ?? '').trim();
|
|
104
|
+
let agentPreset = wantPreset || myPreset;
|
|
105
|
+
if (wantPreset) {
|
|
106
|
+
try {
|
|
107
|
+
const pu = new URL('./lib/preset_resolve.mjs', import.meta.url);
|
|
108
|
+
let pv = '0';
|
|
109
|
+
try { pv = String(statSync(fileURLToPath(pu)).mtimeMs); } catch { /* ignore */ }
|
|
110
|
+
const presetLib = await import(pv === '0' ? pu.href : pu.href + '?v=' + pv);
|
|
111
|
+
const r = await presetLib.resolvePreset(root, wantPreset);
|
|
112
|
+
if (r && r.error) return { ok: false, msg: out.join('\n') + '\n❌ 人设解析失败: ' + r.error };
|
|
113
|
+
if (r && r.id) {
|
|
114
|
+
agentPreset = r.id;
|
|
115
|
+
if (r.via !== 'id') out.push('人设: 「' + wantPreset + '」→ id=' + agentPreset + '(按 ' + r.via + ' 匹配)');
|
|
116
|
+
}
|
|
117
|
+
} catch (e) { out.push('人设解析跳过(沿用原值): ' + String(e?.message ?? e).slice(0, 110)); }
|
|
118
|
+
}
|
|
119
|
+
const permPreset = String(args?.preset ?? 'danger-full-access').trim() || 'danger-full-access';
|
|
120
|
+
out.push('agent 预设 = ' + agentPreset + (wantPreset ? '' : '(继承我自己)') + ' 权限预设 = ' + permPreset);
|
|
121
|
+
let sid = '';
|
|
122
|
+
let created = null;
|
|
123
|
+
try {
|
|
124
|
+
const opts = {};
|
|
125
|
+
if (workspaceId) opts.workspaceId = workspaceId;
|
|
126
|
+
else if (wantWs || myCwd) opts.cwd = wantWs || myCwd;
|
|
127
|
+
opts.agentPreset = agentPreset;
|
|
128
|
+
created = await sc.create(opts);
|
|
129
|
+
sid = String(created?.sessionId ?? created?.id ?? '');
|
|
130
|
+
out.push(`① sessionController.create → ${sid ? 'ok ' + sid : '返回里没 sessionId/id(键: ' + Object.keys(created ?? {}).join(',') + ')'}`);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
out.push('① sessionController.create 抛错: ' + String(e?.message ?? e));
|
|
133
|
+
return { ok: false, msg: out.join('\n') };
|
|
134
|
+
}
|
|
135
|
+
if (!sid) return { ok: false, msg: out.join('\n') + '\n(没拿到会话 id,中止)' };
|
|
136
|
+
|
|
137
|
+
// ── ② 权限:完全权限(走 permissionPresets,不在 create 里)──
|
|
138
|
+
try {
|
|
139
|
+
const pp = svc('permissionPresets');
|
|
140
|
+
if (pp && typeof pp.set === 'function') {
|
|
141
|
+
let sess = null;
|
|
142
|
+
try { sess = svc('sessions')?.get?.(sid) ?? null; } catch { /* ignore */ }
|
|
143
|
+
if (!sess) {
|
|
144
|
+
try { const r = await sc.resolveAgent(sid); sess = r?.agent?.session ?? null; } catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
if (sess) { await pp.set(sess, permPreset); out.push('② 权限预设 → ' + permPreset); }
|
|
147
|
+
else out.push(`② 权限预设跳过(没有 live session 对象;会话默认权限等它首次唤醒后由 preset=${preset} 生效)`);
|
|
148
|
+
} else {
|
|
149
|
+
out.push('② permissionPresets 服务不可用(跳过;create 时已带 agentPreset)');
|
|
150
|
+
}
|
|
151
|
+
} catch (e) { out.push('② 权限设置失败(继续): ' + String(e?.message ?? e)); }
|
|
152
|
+
|
|
153
|
+
// ── ③ 初始任务 ──
|
|
154
|
+
const task = String(args?.task ?? '').trim();
|
|
155
|
+
if (task && typeof sc.prompt === 'function') {
|
|
156
|
+
// ✅ 权威形状(dsh-api-session-controller/lib/types/types.d.ts:319 SessionPromptRequest):
|
|
157
|
+
// { requestId, sessionId, mode: 'queue'|'steer', content: PromptContentPart[], clientTimeZone? }
|
|
158
|
+
// 之前传 {sessionId, text} 全错 —— content 位置放字符串导致内部解析炸 `signal.throwIfAborted`。
|
|
159
|
+
// mode='queue' 排队(不打断);mode='steer' 插话(等于 qqbot 的 interject)。
|
|
160
|
+
const rid = (globalThis.crypto?.randomUUID?.() ?? ('req-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)));
|
|
161
|
+
const body = [{ type: 'text', text: task }];
|
|
162
|
+
const sig = new AbortController().signal;
|
|
163
|
+
// ★ 关键:signal 是**独立的第二参数**(同文件 page(request, signal) / list(signal) / control(signal) 同款),
|
|
164
|
+
// 塞进 request 里它读不到 → 报 `undefined.throwIfAborted`。
|
|
165
|
+
const req = { requestId: rid, sessionId: sid, mode: 'queue', content: body };
|
|
166
|
+
const shapes = [
|
|
167
|
+
['(request, signal) mode=queue', () => sc.prompt(req, sig)],
|
|
168
|
+
['(request) mode=queue', () => sc.prompt(req)],
|
|
169
|
+
['(request, signal) mode=steer', () => sc.prompt({ ...req, mode: 'steer' }, sig)],
|
|
170
|
+
];
|
|
171
|
+
let via = '';
|
|
172
|
+
const errs = [];
|
|
173
|
+
for (const [lb, fn] of shapes) {
|
|
174
|
+
try { await fn(); via = lb; break; } catch (e) { errs.push(lb + '→' + String(e?.message ?? e).slice(0, 60)); }
|
|
175
|
+
}
|
|
176
|
+
out.push('③ 初始任务 → ' + (via ? 'ok(形式: ' + via + ')' : '全部形式被拒: ' + errs.join(' | ')));
|
|
177
|
+
} else if (task) {
|
|
178
|
+
out.push('③ 初始任务跳过(sessionController 没有 prompt())');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── ④ 标题(可选)──
|
|
182
|
+
const label = String(args?.name ?? '').trim();
|
|
183
|
+
if (label && typeof sc.rename === 'function') {
|
|
184
|
+
try { await sc.rename({ sessionId: sid, title: label }); out.push('④ 标题 → ' + label); }
|
|
185
|
+
catch (e) { out.push('④ 标题设置失败: ' + String(e?.message ?? e).slice(0, 80)); }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── ⑤ 回合结束回灌钩子 ──
|
|
189
|
+
let watchMsg = '未挂(watch=false)';
|
|
190
|
+
if (args?.watch !== false) {
|
|
191
|
+
try {
|
|
192
|
+
const w = await loadWatchLib();
|
|
193
|
+
watchMsg = await w.register(root, { parentAgentId: me, childSessionId: sid, label }) +
|
|
194
|
+
' [lib=' + (w.__LIB_VERSION__ ?? '未知(旧版无版本号)') + ']';
|
|
195
|
+
} catch (e) { watchMsg = '未挂(' + String(e?.message ?? e) + ')'; }
|
|
196
|
+
}
|
|
197
|
+
out.push('⑤ 回合结束回灌 → ' + watchMsg);
|
|
198
|
+
|
|
199
|
+
return { ok: true, msg: out.join('\n') + `\n\n新会话 id = ${sid}\n(用 session_watch 看钩子名单;用 session_children 看血缘)` };
|
|
200
|
+
},
|
|
201
|
+
};
|