@zaofan/dsh-qqbot 1.5.31 → 1.6.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.
@@ -8,6 +8,13 @@ export interface QQChannel {
8
8
  }
9
9
  export declare const name = "qqbot-channel-tools";
10
10
  export declare const inject: string[];
11
+ export declare function setContextlessSmartGlobal(v: boolean): void;
12
+ /** 读登记(session-manager 的 pre-step 用) */
13
+ export declare function readContextPending(dataRoot: string, sessionKey: string): {
14
+ mode?: string;
15
+ keep?: number;
16
+ } | undefined;
17
+ export declare function clearContextPending(dataRoot: string, sessionKey: string): void;
11
18
  export declare function setChannelBridge(b: QQChannel | undefined): void;
12
19
  /** 装载本插件时把 qqChannel 一并注入(由 dsh-qqbot setup 提供) */
13
20
  export declare function apply(ctx: Context): Promise<void>;
@@ -26,4 +33,15 @@ export declare function askAiToWriteSamples(ns: string, scope: 'group' | 'c2c',
26
33
  ok: boolean;
27
34
  msg: string;
28
35
  }>;
36
+ export declare function setCtxSmartText(t: string): void;
37
+ export declare function getCtxSmartText(): string;
38
+ /**
39
+ * 按会话开关**动态**注册/注销三个上下文工具(context_memo / context_compact / context_drop)。
40
+ * 由 session-manager 在每个回合的 pre-step 里调用(拿 rec.agentCtx)。
41
+ * 这样面板一改开关,**下一回合就生效**,不需要重启,也没有"apply 时读全局变量"的时序竞态。
42
+ */
43
+ export declare function syncContextTools(agentCtx: unknown, shouldHave: boolean, env?: {
44
+ dataRoot?: string;
45
+ sessionKey?: string;
46
+ }, reminderText?: string): void;
29
47
  //# sourceMappingURL=channel-tools.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AA2ElE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAC1C,eAAO,MAAM,MAAM,UAAY,CAAC;AAShC,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAI/D;AAkFD,mDAAmD;AACnD,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA63DvD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwCvC"}
1
+ {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AA+ElE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAG1C,eAAO,MAAM,MAAM,UAA4B,CAAC;AAWhD,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAiC;AA+B5F,wCAAwC;AACxC,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAErH;AACD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAE9E;AACD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAI/D;AAkFD,mDAAmD;AACnD,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA+4DvD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwCvC;AAiID,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAyI;AACzL,wBAAgB,eAAe,IAAI,MAAM,CAA2F;AA0BpI;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CA+BtJ"}
@@ -8,8 +8,8 @@
8
8
  * 复制进共享 node_modules,也不得在 dependencies 里自带 dsh-tools(避免双包)。
9
9
  */
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools';
11
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
- import { join } from 'node:path';
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
13
  import { getStickerStore } from './features/sticker-store.js';
14
14
  import { autoTagImage } from './features/sticker-tagger.js';
15
15
  import { isStickerGateDenied } from './features/sticker-gate.js';
@@ -24,6 +24,7 @@ import { managersOf, findManagerByPeer, findManagerBySessionId } from './feature
24
24
  import { handleInbound } from './transport/inbound.js';
25
25
  import { getQuote } from './features/quote-cache.js';
26
26
  import { appendMemoLine, deleteMemo, listMemos, readMemo } from './features/people-memo.js';
27
+ import { appendMemo as appendContextMemo, clearMemo as clearContextMemo, deleteMemo as deleteContextMemo, editMemo as editContextMemo, listMemo as listContextMemo, } from './features/context-memo.js';
27
28
  /** 诊断日志路径: 默认关闭; 需要排查时设环境变量 QQBOT_DIAG_FILE 指向日志文件 */
28
29
  const DIAG_FILE = process.env.QQBOT_DIAG_FILE || '';
29
30
  /** 大文件异步发送阈值: 本地文件 >= 5MB 时走后台任务(SDK 分片上传耗时, 避免阻塞 LLM 回合) */
@@ -93,7 +94,9 @@ function extDiag(line) {
93
94
  catch { /* ignore */ }
94
95
  }
95
96
  export const name = 'qqbot-channel-tools';
96
- export const inject = ['tools'];
97
+ // ⚠️ 2026-10-02:用 ctx.systemPrompt.context() 注册运行时贡献**必须**在此声明依赖,
98
+ // 否则 ctx.systemPrompt 拿不到 → 注册走"宿主未提供"兜底 → 静默不注入(与当年小传那个坑同源)。
99
+ export const inject = ['tools', 'systemPrompt'];
97
100
  /**
98
101
  * 运行时按当前执行 agent 解析 qqChannel service。
99
102
  * 解析顺序: ①exec.agent.ctx.get('qqChannel')(agent ctx, setup 时 provide);
@@ -101,6 +104,56 @@ export const inject = ['tools'];
101
104
  * 防止 setup 未执行/竞态导致"不是 QQ 会话"误判(线上踩坑: 重启后 setup 未跑到)。
102
105
  */
103
106
  let channelBridges = [];
107
+ /** 全局「智能判断」开关(由 session-manager 初始化时注入;照 setChannelBridge 同款做法) */
108
+ let ctxSmartGlobal = false;
109
+ export function setContextlessSmartGlobal(v) { ctxSmartGlobal = v === true; }
110
+ function getContextlessSmartGlobal() { return ctxSmartGlobal; }
111
+ /** 无上下文·智能判断:取当前会话的 dataRoot + sessionKey */
112
+ async function resolveCtxMemoEnv(exec) {
113
+ // ★ 优先用注册时记住的会话坐标(工具里的 exec.agent 与 pre-step 的 agent 是两个不同对象,反查常失败)
114
+ const cached = ctxEnvOf(exec);
115
+ if (cached)
116
+ return cached;
117
+ try {
118
+ const got = findSessionRec(channelOf(exec), exec);
119
+ const dataRoot = got?.ch?.manager?.config?.dataRoot;
120
+ const sessionKey = got?.rec?.sessionKey;
121
+ if (!dataRoot || !sessionKey)
122
+ return undefined;
123
+ return { dataRoot: String(dataRoot), sessionKey: String(sessionKey) };
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /** 登记文件路径(工具只登记,真正压缩在下一回合 pre-step) */
130
+ function ctxPendingPath(dataRoot, sessionKey) {
131
+ // ⚠️ 同上:不能保留冒号(Windows 文件名禁用 `:`,会导致 pending 写不进/读不到)
132
+ const safe = String(sessionKey).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 60);
133
+ return join(dataRoot, '.qqbot', 'pending-compact', safe + '.json');
134
+ }
135
+ function writeContextPending(dataRoot, sessionKey, o) {
136
+ try {
137
+ mkdirSync(dirname(ctxPendingPath(dataRoot, sessionKey)), { recursive: true });
138
+ writeFileSync(ctxPendingPath(dataRoot, sessionKey), JSON.stringify(o), 'utf8');
139
+ }
140
+ catch { /* ignore */ }
141
+ }
142
+ /** 读登记(session-manager 的 pre-step 用) */
143
+ export function readContextPending(dataRoot, sessionKey) {
144
+ try {
145
+ return JSON.parse(readFileSync(ctxPendingPath(dataRoot, sessionKey), 'utf8'));
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ }
151
+ export function clearContextPending(dataRoot, sessionKey) {
152
+ try {
153
+ rmSync(ctxPendingPath(dataRoot, sessionKey), { force: true });
154
+ }
155
+ catch { /* ignore */ }
156
+ }
104
157
  export function setChannelBridge(b) {
105
158
  if (!b)
106
159
  return;
@@ -2123,12 +2176,24 @@ export async function apply(ctx) {
2123
2176
  },
2124
2177
  });
2125
2178
  // 逐个注册并记录结果(便于线上定位是哪个工具失败)
2179
+ // 供 syncContextTools 动态挂载用(apply 早于 pre-step,填好即可)
2180
+ // ★ 自动热刷(2026-10-02):apply 早于任何回合 ⇒ 在这里注册一次,第一回合的工具表里就有,
2181
+ // 不必依赖 pre-step(那只在回合内生效,会滞后一回合 —— 表现为"工具不存在,得 tools_reload")。
2182
+ // 之后 pre-step 仍按会话开关刷新:开则保持、关则注销。
2183
+ try {
2184
+ syncContextTools(ctx, true);
2185
+ }
2186
+ catch { /* ignore */ }
2126
2187
  const toolDefs = [
2127
2188
  { name: 'send_media', tool: sendMediaTool },
2128
2189
  { name: 'recall_message', tool: recallTool },
2129
2190
  { name: 'text_break', tool: textBreakTool },
2130
2191
  { name: 'quote_view', tool: quoteViewTool },
2131
2192
  { name: 'people_memo', tool: peopleMemoTool },
2193
+ // 无上下文模式·智能判断专用(全局开关 config.contextlessSmart 开启时才出现)
2194
+ ...(getContextlessSmartGlobal() === true
2195
+ ? []
2196
+ : []),
2132
2197
  { name: 'tools_reload', tool: toolsReloadTool },
2133
2198
  { name: 'reply_gate', tool: replyGateTool },
2134
2199
  { name: 'outbound_mode', tool: outboundModeTool },
@@ -2359,4 +2424,208 @@ export async function askAiToWriteSamples(ns, scope, peerId) {
2359
2424
  return { ok: false, msg: `异常: ${e instanceof Error ? e.message : String(e)}` };
2360
2425
  }
2361
2426
  }
2427
+ /** 每个 agentCtx 已注册的上下文工具 disposer(WeakMap:多会话互不影响) */
2428
+ const ctxToolDisposers = new WeakMap();
2429
+ /** 三个上下文工具定义(在 apply() 里赋值 —— 那里才拿得到 defineTool 的产物) */
2430
+ var contextMemoTool = defineTool({
2431
+ name: 'context_memo',
2432
+ description: '查看/追加/修改/删除/清空「你自己的备忘」。备忘存在独立文件里,压缩或丢弃历史时**永远不会被丢**;'
2433
+ + '用来长期留着跨话题需要记住的事(对方偏好、未完成的事、之前聊过什么)。',
2434
+ parameters: {
2435
+ action: {
2436
+ type: 'string',
2437
+ required: true,
2438
+ enum: ['list', 'append', 'edit', 'delete', 'clear'],
2439
+ description: 'list=查看全部 / append=追加一条 / edit=改某条 / delete=删某条 / clear=全清',
2440
+ },
2441
+ text: { type: 'string', description: 'append/edit 时的内容(要留下的要点)' },
2442
+ id: { type: 'string', description: 'edit/delete 时的条目 id(先 list 拿到)' },
2443
+ },
2444
+ output: {
2445
+ schema: {
2446
+ type: 'object',
2447
+ additionalProperties: true,
2448
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
2449
+ },
2450
+ render: (_a, v) => [{ type: 'text', text: String(v?.msg ?? '') }],
2451
+ },
2452
+ async execute(args, exec) {
2453
+ const r = await resolveCtxMemoEnv(exec);
2454
+ if (!r)
2455
+ return { ok: false, msg: '未找到当前会话,无法读写备忘' };
2456
+ const a = (args ?? {});
2457
+ const action = String(a.action || 'list');
2458
+ try {
2459
+ if (action === 'list') {
2460
+ const items = listContextMemo(r.dataRoot, r.sessionKey);
2461
+ return {
2462
+ ok: true,
2463
+ msg: items.length ? items.map((x) => '[' + x.id + '] ' + x.text).join('\n') : '(备忘是空的)',
2464
+ };
2465
+ }
2466
+ if (action === 'append') {
2467
+ const it = appendContextMemo(r.dataRoot, r.sessionKey, String(a.text || ''));
2468
+ return it ? { ok: true, msg: '已追加备忘 [' + it.id + ']' } : { ok: false, msg: 'text 不能为空' };
2469
+ }
2470
+ if (action === 'edit') {
2471
+ const ok = editContextMemo(r.dataRoot, r.sessionKey, String(a.id || ''), String(a.text || ''));
2472
+ return { ok, msg: ok ? '已修改 [' + String(a.id) + ']' : '没找到该 id,或 text 为空' };
2473
+ }
2474
+ if (action === 'delete') {
2475
+ const ok = deleteContextMemo(r.dataRoot, r.sessionKey, String(a.id || ''));
2476
+ return { ok, msg: ok ? '已删除 [' + String(a.id) + ']' : '没找到该 id' };
2477
+ }
2478
+ if (action === 'clear') {
2479
+ const cnt = clearContextMemo(r.dataRoot, r.sessionKey);
2480
+ return { ok: true, msg: '已清空 ' + cnt + ' 条' };
2481
+ }
2482
+ return { ok: false, msg: '未知 action' };
2483
+ }
2484
+ catch (e) {
2485
+ return { ok: false, msg: String(e?.message ?? e) };
2486
+ }
2487
+ },
2488
+ });
2489
+ var contextCompactTool = defineTool({
2490
+ name: 'context_compact',
2491
+ description: '压缩本会话的上下文:把旧历史压掉、只留最近几条,真正释放 token。'
2492
+ + '本工具只**登记**,会在下一个回合开始时执行(回合内无法直接压)。'
2493
+ + 'note 参数用来写「压缩后仍然需要记住的要点」——它存进你的备忘,压缩碰不到。',
2494
+ parameters: {
2495
+ note: { type: 'string', description: '要点:压缩后仍需要记住的信息(会存进备忘)' },
2496
+ keep: { type: 'number', description: '保留最近几条消息(默认 3;0 = 全清)' },
2497
+ },
2498
+ output: {
2499
+ schema: {
2500
+ type: 'object',
2501
+ additionalProperties: true,
2502
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
2503
+ },
2504
+ render: (_a, v) => [{ type: 'text', text: String(v?.msg ?? '') }],
2505
+ },
2506
+ async execute(args, exec) {
2507
+ const r = await resolveCtxMemoEnv(exec);
2508
+ if (!r)
2509
+ return { ok: false, msg: '未找到当前会话' };
2510
+ const a = (args ?? {});
2511
+ if (a.note)
2512
+ appendContextMemo(r.dataRoot, r.sessionKey, String(a.note));
2513
+ writeContextPending(r.dataRoot, r.sessionKey, { mode: 'compact', keep: Number(a.keep) || 3, at: Date.now() });
2514
+ return { ok: true, msg: '已登记压缩:下一个回合开始时执行'
2515
+ + (a.note ? '(要点已存进你的备忘)' : '(本次没写要点;若压缩后有需要记住的,请带上 note)') };
2516
+ },
2517
+ });
2518
+ var contextDropTool = defineTool({
2519
+ name: 'context_drop',
2520
+ description: '丢弃本会话的历史上下文(只留下你自己写的备忘)。话题彻底结束、不再需要原文时用它。'
2521
+ + '本工具只**登记**,会在下一个回合开始时执行(回合内无法直接压)。'
2522
+ + '调用前请用 note 参数写下要点——它存进你的备忘,丢弃碰不到。',
2523
+ parameters: {
2524
+ note: { type: 'string', description: '要点:丢弃后仍需要记住的信息(会存进备忘)' },
2525
+ },
2526
+ output: {
2527
+ schema: {
2528
+ type: 'object',
2529
+ additionalProperties: true,
2530
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
2531
+ },
2532
+ render: (_a, v) => [{ type: 'text', text: String(v?.msg ?? '') }],
2533
+ },
2534
+ async execute(args, exec) {
2535
+ const r = await resolveCtxMemoEnv(exec);
2536
+ if (!r)
2537
+ return { ok: false, msg: '未找到当前会话' };
2538
+ const a = (args ?? {});
2539
+ if (a.note)
2540
+ appendContextMemo(r.dataRoot, r.sessionKey, String(a.note));
2541
+ writeContextPending(r.dataRoot, r.sessionKey, { mode: 'drop', keep: 0, at: Date.now() });
2542
+ return { ok: true, msg: '已登记丢弃:下一个回合开始时执行'
2543
+ + (a.note ? '(要点已存进你的备忘)' : '(本次没写要点;丢弃后这些历史就找不回来了,建议带 note)') };
2544
+ },
2545
+ });
2546
+ // 模块级直接拿(不依赖 apply —— apply 只在会话首建时跑,agent 重建后不会重跑)
2547
+ // 注意:三个工具定义在同文件下方(var 声明,有提升),故这里用取值函数避免 TDZ。
2548
+ /**
2549
+ * 智能提示的运行时文本(照小传的 pending 模式):
2550
+ * session-manager 在 pre-step 里拼好 → 塞进来;index.ts 注册的 systemPrompt.context 只负责读。
2551
+ * ⚠️ 注册必须发生在**主插件的 ctx** 上(见 index.ts 小传那段)—— 注册在 agentCtx 上会随 agent 重建丢失。
2552
+ */
2553
+ let ctxSmartTextPending = '';
2554
+ export function setCtxSmartText(t) { ctxSmartTextPending = String(t || ''); if (ctxSmartTextPending)
2555
+ console.log('[spctx-set] 已塞入 ' + ctxSmartTextPending.length + ' 字'); }
2556
+ export function getCtxSmartText() { if (!ctxSmartTextPending)
2557
+ console.log('[spctx-get] 读时为空'); return ctxSmartTextPending; }
2558
+ let CTX_TOOLS_REF = [];
2559
+ /** 惰性取三件套(首次调用时求值,那时定义已就绪) */
2560
+ function ctxToolsRef() {
2561
+ if (CTX_TOOLS_REF.length === 0) {
2562
+ try {
2563
+ CTX_TOOLS_REF = [contextMemoTool, contextCompactTool, contextDropTool];
2564
+ }
2565
+ catch { /* ignore */ }
2566
+ }
2567
+ return CTX_TOOLS_REF;
2568
+ }
2569
+ const ctxEnvMap = new WeakMap();
2570
+ /** 工具执行时取会话坐标:优先用注册时记下的(最准),取不到再回退反查 */
2571
+ function ctxEnvOf(exec) {
2572
+ try {
2573
+ const c = exec?.agent?.ctx;
2574
+ if (c && typeof c === 'object') {
2575
+ const hit = ctxEnvMap.get(c);
2576
+ if (hit && hit.dataRoot && hit.sessionKey)
2577
+ return hit;
2578
+ }
2579
+ }
2580
+ catch { /* fallthrough */ }
2581
+ return undefined;
2582
+ }
2583
+ /**
2584
+ * 按会话开关**动态**注册/注销三个上下文工具(context_memo / context_compact / context_drop)。
2585
+ * 由 session-manager 在每个回合的 pre-step 里调用(拿 rec.agentCtx)。
2586
+ * 这样面板一改开关,**下一回合就生效**,不需要重启,也没有"apply 时读全局变量"的时序竞态。
2587
+ */
2588
+ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2589
+ void reminderText; // 保留签名(提示文本改由主 ctx 的 systemPrompt.context 提供,见 index.ts)
2590
+ if (!agentCtx || typeof agentCtx !== 'object')
2591
+ return;
2592
+ const ctxObj = agentCtx;
2593
+ const key = agentCtx;
2594
+ try {
2595
+ // ① 先记会话坐标(每次 pre-step 都刷新;这是工具执行时唯一的可靠来源)
2596
+ if (env && env.dataRoot && env.sessionKey) {
2597
+ ctxEnvMap.set(key, { dataRoot: String(env.dataRoot), sessionKey: String(env.sessionKey) });
2598
+ }
2599
+ const cur = ctxToolDisposers.get(key);
2600
+ // ⚠️ 2026-10-02:不能靠"缓存里已注册"就跳过 —— agent/ctx 一变,实际工具表就作废了,
2601
+ // 而缓存还记着"注册过" ⇒ 永久挂不上(实测:工具表里没有 context_*,但日志显示 syncTools 在跑)。
2602
+ // 改为每次都"先注销旧、再重新注册",幂等刷新,代价极小。
2603
+ if (cur) {
2604
+ for (const d of cur) {
2605
+ try {
2606
+ d();
2607
+ }
2608
+ catch { /* ignore */ }
2609
+ }
2610
+ ctxToolDisposers.delete(key);
2611
+ }
2612
+ if (shouldHave) {
2613
+ const reg = ctxObj.tools?.register;
2614
+ if (typeof reg !== 'function')
2615
+ return;
2616
+ const ds = [];
2617
+ for (const t of ctxToolsRef()) {
2618
+ try {
2619
+ const d = reg.call(ctxObj.tools, t);
2620
+ if (typeof d === 'function')
2621
+ ds.push(d);
2622
+ }
2623
+ catch { /* 单个工具注册失败不影响其他 */ }
2624
+ }
2625
+ if (ds.length)
2626
+ ctxToolDisposers.set(key, ds);
2627
+ }
2628
+ }
2629
+ catch { /* fail-soft:工具挂载失败绝不影响会话 */ }
2630
+ }
2362
2631
  //# sourceMappingURL=channel-tools.js.map