@zaofan/dsh-qqbot 1.10.14 → 1.10.16

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.
Files changed (36) hide show
  1. package/README.md +25 -0
  2. package/dist/channel-tools.d.ts +6 -3
  3. package/dist/channel-tools.d.ts.map +1 -1
  4. package/dist/channel-tools.js +226 -18
  5. package/dist/channel-tools.js.map +1 -1
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +8 -1
  8. package/dist/config.js.map +1 -1
  9. package/dist/features/botplay-ext.d.ts +4 -0
  10. package/dist/features/botplay-ext.d.ts.map +1 -1
  11. package/dist/features/botplay-ext.js +4 -1
  12. package/dist/features/botplay-ext.js.map +1 -1
  13. package/dist/features/md-media-rewrite.d.ts +17 -0
  14. package/dist/features/md-media-rewrite.d.ts.map +1 -0
  15. package/dist/features/md-media-rewrite.js +151 -0
  16. package/dist/features/md-media-rewrite.js.map +1 -0
  17. package/dist/features/md-media.d.ts +47 -0
  18. package/dist/features/md-media.d.ts.map +1 -0
  19. package/dist/features/md-media.js +219 -0
  20. package/dist/features/md-media.js.map +1 -0
  21. package/dist/features/reasoning-bus.d.ts +36 -0
  22. package/dist/features/reasoning-bus.d.ts.map +1 -0
  23. package/dist/features/reasoning-bus.js +65 -0
  24. package/dist/features/reasoning-bus.js.map +1 -0
  25. package/dist/gateway/bootstrap.d.ts.map +1 -1
  26. package/dist/gateway/bootstrap.js +36 -1
  27. package/dist/gateway/bootstrap.js.map +1 -1
  28. package/dist/transport/events.d.ts +4 -0
  29. package/dist/transport/events.d.ts.map +1 -1
  30. package/dist/transport/events.js +3 -0
  31. package/dist/transport/events.js.map +1 -1
  32. package/dist/transport/outbound.d.ts.map +1 -1
  33. package/dist/transport/outbound.js +45 -1
  34. package/dist/transport/outbound.js.map +1 -1
  35. package/examples/ext-outbound/report-relay.mjs +109 -0
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -728,3 +728,28 @@ autoRecall:
728
728
  ## License
729
729
 
730
730
  [MIT](./LICENSE)
731
+
732
+ ---
733
+
734
+ ## 正文图组:markdown 图片直接发
735
+
736
+ DSH 前端会把**回复正文里的 markdown 图片**渲染成图;本插件把同一段文本**原样转发到 QQ** ——
737
+ 本地路径会自动上传成 `qqbot-media` 引用(读文件头补真实宽高,配额满 100 张自动删最早),不需要调用任何工具。
738
+
739
+ ```markdown
740
+ ![干饭女王 #400px](D:/pics/a.jpg)
741
+
742
+ 第一幕:本鲸宣布今晚不睡了
743
+
744
+ ![何意味 #400px](D:/pics/b.jpg)
745
+ ```
746
+
747
+ **一行一张、连续几行就是图组**(小剧场 / 分镜 / 图文并排),QQ 侧按顺序渲染成一屏。
748
+
749
+ | DSH 前端 | QQ 群 |
750
+ | :---: | :---: |
751
+ | ![DSH 前端显示](docs/images/dsh-md-images.png) | ![QQ 群显示](docs/images/qq-md-images.jpg) |
752
+
753
+ 配合表情包库最顺手:`list_stickers` 搜到图 → 把本地路径写进正文 → 图组直接出来,比连发 `send_media` 更像翻漫画。
754
+
755
+ 失败策略:图传不上去时会**原样留在文字里**(不阻断本条,不会吞掉正文)。
@@ -111,13 +111,16 @@ export declare function syncContextTools(agentCtx: unknown, shouldHave: boolean,
111
111
  export declare function ensureExtToolsGlobal(globalCtx: unknown): void;
112
112
  export declare function ensureContextToolsGlobal(globalCtx: unknown): void;
113
113
  /**
114
- * 按会话**实时剪枝**三个上下文工具的可见性(2026-10-09,主人要的"实时变更")。
114
+ * 按会话**实时剪枝**三个上下文工具的可见性(2026-10-09 起;2026-10-21 按 `dsh-computer-use` 的实测修)。
115
115
  *
116
116
  * 语义:**开了「无上下文模式 + 智能判断」**的会话 → 看得见那三个工具;
117
117
  * 否则该会话**连工具名都看不到**(模型清单里没有 = 零 token),而**注册表纹丝不动**。
118
- * 面板一改开关 → 下一次 pre-step(下一回合/下一 step)即生效;撤销限制是立即的。
118
+ * 面板一改开关 → 下一次 pre-step(下一回合 / 下一 step)即生效。
119
119
  *
120
- * 幂等:同一作用域状态没变化就直接 return,不重复设/撤 restriction(避免无谓的变更通知与抖动)。
120
+ * 2026-10-21 修两处(对齐 `dsh-plugins/dsh-computer-use/internal/schema-visibility.mjs`):
121
+ * ① `ctx.tools` 在未 inject 时会抛 ⇒ 必须退化到 `ctx.get('tools')`,否则"剪枝"整段被 catch 吞掉;
122
+ * ② `restrict()` 的返回值是**函数**,释放必须走 {@link liftRestriction}。
123
+ * 幂等:同一作用域状态没变化就直接 return,不重复设/撤 restriction(避免无谓变更通知与抖动)。
121
124
  */
122
125
  export declare function restrictContextTools(agentCtx: unknown, shouldHave: boolean, env?: {
123
126
  dataRoot?: string;
@@ -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;AAInD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AA8FlE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAI1C,eAAO,MAAM,MAAM,UAAY,CAAC;AAgLhC,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAiC;AAkD5F,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,CAgzEvD;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,CAkBvC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BvC;AAOD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM;;;;;;;;;IAE1C;AAED,yCAAyC;AACzC,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAExF;AAYD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAExD;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAKxI;AA4RD,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAyI;AACzL,wBAAgB,eAAe,IAAI,MAAM,CAA2F;AA8CpI;;;;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+CtJ;AA0BD;;;;;GAKG;AACH;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAkH7D;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAoCjE;AAKD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,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,GAAG,IAAI,CA2BnI"}
1
+ {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,8 @@
8
8
  * 复制进共享 node_modules,也不得在 dependencies 里自带 dsh-tools(避免双包)。
9
9
  */
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { uploadMarkdownImage, listMarkdownImages, deleteMarkdownImage } from './features/md-media.js';
12
+ import { setMaterialTargetFactory } from './features/md-media-rewrite.js';
11
13
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
14
  import { join, dirname } from 'node:path';
13
15
  import { homedir } from 'node:os';
@@ -169,6 +171,19 @@ function findChannelByTag(tag) {
169
171
  * @param tag 人设 id(如 whale)
170
172
  * @param dataRoot 插件数据根(群台账在这里)
171
173
  */
174
+ // 2026-10-21: 出站自动传图需要 groupAdmin 句柄;取**任一**桥(不依赖当前会话)
175
+ setMaterialTargetFactory(() => {
176
+ try {
177
+ // 2026-10-21 修:channelBridges 实测是空的(同文件 204 行注释也写了),要用 managersOf()
178
+ for (const m of managersOf()) {
179
+ const cc = m.groupAdmin;
180
+ if (cc)
181
+ return { apiCall: cc.apiCall.bind(cc) };
182
+ }
183
+ }
184
+ catch { /* ignore */ }
185
+ return undefined;
186
+ });
172
187
  function buildWorkspaceQqPack(tag, dataRoot) {
173
188
  const hit = findChannelByTag(tag);
174
189
  if (!hit)
@@ -290,9 +305,63 @@ function buildWorkspaceQqPack(tag, dataRoot) {
290
305
  },
291
306
  sendMarkdown: (scope, id, md) => (typeof sender?.sendMarkdown === 'function' && id ? Promise.resolve(sender.sendMarkdown(toTarget(scope, id), String(md))).then(() => true).catch(() => false) : Promise.resolve(false)),
292
307
  sendImage: (scope, id, src) => (typeof sender?.sendMedia === 'function' && id ? Promise.resolve(sender.sendMedia(toTarget(scope, id), 'image', src)).then(() => true).catch(() => false) : Promise.resolve(false)),
293
- apiCall: typeof mgr?.groupAdmin?.apiCall === 'function'
294
- ? (m, p, b) => mgr.groupAdmin.apiCall(m, p, b)
295
- : undefined,
308
+ // 2026-10-22(主人定):素材上传等本来**不需要群管理权限** —— 这里直接用机器人自己的
309
+ // access_token 通道(与 api/group-admin.ts 同源),不再借 groupAdmin,也不依赖本实例是否启用群管理。
310
+ apiCall: (() => {
311
+ const cred = globalThis.__qqbotAppCred;
312
+ const appId = String(mgr?.config?.appId || cred?.appId || '');
313
+ const appSecret = String(mgr?.config?.appSecret || cred?.appSecret || '');
314
+ if (!appId || !appSecret)
315
+ return undefined;
316
+ const g = globalThis;
317
+ if (!g.__qqbotBotToken)
318
+ g.__qqbotBotToken = new Map();
319
+ const cache = g.__qqbotBotToken;
320
+ const token = async () => {
321
+ const hit = cache.get(appId);
322
+ if (hit && Date.now() < hit.at)
323
+ return hit.t;
324
+ try {
325
+ const res = await fetch('https://bots.qq.com/app/getAppAccessToken', {
326
+ method: 'POST',
327
+ headers: { 'content-type': 'application/json' },
328
+ body: JSON.stringify({ appId, clientSecret: appSecret }),
329
+ });
330
+ const j = (await res.json());
331
+ if (!j.access_token)
332
+ return undefined;
333
+ cache.set(appId, { t: j.access_token, at: Date.now() + ((j.expires_in ?? 7200) - 60) * 1000 });
334
+ return j.access_token;
335
+ }
336
+ catch {
337
+ return undefined;
338
+ }
339
+ };
340
+ return async (method, path, body) => {
341
+ const tk = await token();
342
+ if (!tk)
343
+ return { ok: false, err: { code: 'no-token', human: '取不到 access_token(appId/appSecret 缺失或网络失败)' } };
344
+ try {
345
+ const res = await fetch('https://api.sgroup.qq.com' + path, {
346
+ method,
347
+ headers: { Authorization: 'QQBot ' + tk, 'content-type': 'application/json' },
348
+ body: body === undefined ? undefined : JSON.stringify(body),
349
+ });
350
+ const text = await res.text();
351
+ let data = {};
352
+ try {
353
+ data = text ? JSON.parse(text) : {};
354
+ }
355
+ catch {
356
+ data = { raw: text };
357
+ }
358
+ return { ok: res.ok, data, err: res.ok ? undefined : { code: res.status, human: text.slice(0, 200) } };
359
+ }
360
+ catch (e) {
361
+ return { ok: false, err: { code: 'net', human: e?.message ?? String(e) } };
362
+ }
363
+ };
364
+ })(),
296
365
  };
297
366
  }
298
367
  /** 全局「智能判断」开关(由 session-manager 初始化时注入;照 setChannelBridge 同款做法) */
@@ -2336,6 +2405,91 @@ export async function apply(ctx) {
2336
2405
  }
2337
2406
  },
2338
2407
  });
2408
+ /** 素材管理(2026-10-21 新接口 /v2/media-images/*:本地图 → qqbot-media:// 引用串) */
2409
+ function anyAdminOf(exec) {
2410
+ try {
2411
+ const s = findSessionRec(channelOf(exec), exec);
2412
+ const client = s?.ch?.manager?.groupAdmin;
2413
+ const root = String(s?.ch?.manager?.config?.dataRoot ?? '');
2414
+ if (client)
2415
+ return { client, dataRoot: root };
2416
+ for (const b of channelBridges) {
2417
+ try {
2418
+ const c = b.manager.groupAdmin;
2419
+ if (c)
2420
+ return { client: c, dataRoot: String(b.manager.config?.dataRoot ?? '') };
2421
+ }
2422
+ catch { /* 跳过坏桥 */ }
2423
+ }
2424
+ return undefined;
2425
+ }
2426
+ catch {
2427
+ return undefined;
2428
+ }
2429
+ }
2430
+ const materialTool = defineTool({
2431
+ name: 'qq_material',
2432
+ description: 'QQ 素材管理(Markdown 图片): 把**本地图片**传到 QQ 平台换 qqbot-media:// 引用串,可直接写进 markdown 消息(不再需要公网图床)。限制: 单张 ≤10MB、png/jpeg/gif/webp、单机器人最多 100 张、图片滑动过期 90 天(每次被引用顺延)。action=upload 传图 / list 看已传列表 / delete 删除释放配额。',
2433
+ parameters: {
2434
+ action: { type: 'string', enum: ['upload', 'list', 'delete'], description: '默认 upload' },
2435
+ image: { type: 'string', description: 'upload: 本地图片绝对路径' },
2436
+ image_id: { type: 'string', description: 'delete: 图片 ID(list 里能看到)' },
2437
+ cursor: { type: 'string', description: 'list: 翻页游标(首页不传)' },
2438
+ },
2439
+ output: {
2440
+ schema: {
2441
+ type: 'object',
2442
+ properties: { ok: { type: 'boolean' }, msg: { type: 'string' }, url: { type: 'string' }, markdown: { type: 'string' } },
2443
+ additionalProperties: false,
2444
+ },
2445
+ render: (_a, v) => [{ type: 'text', text: String(v.msg || (v.ok ? 'ok' : '失败')) }],
2446
+ },
2447
+ execute: async (args, exec) => {
2448
+ const mat = anyAdminOf(exec);
2449
+ if (!mat)
2450
+ return { ok: false, msg: '拿不到机器人实例(没有已连接的本)' };
2451
+ const target = { apiCall: mat.client.apiCall.bind(mat.client) };
2452
+ const action = String(args.action ?? 'upload').trim() || 'upload';
2453
+ if (action === 'upload') {
2454
+ const r = await uploadMarkdownImage(target, String(args.image ?? '').trim());
2455
+ if (!r.ok)
2456
+ return { ok: false, msg: '上传失败: ' + String(r.msg || '') };
2457
+ const name = String(args.image ?? '').split(/[\\/]/).pop() || 'image';
2458
+ const dim = r.width && r.height ? ' #' + r.width + 'px #' + r.height + 'px' : '';
2459
+ const markdown = '![' + name + dim + '](' + String(r.url) + ')';
2460
+ const head = (r.cached ? '✅ 命中本地缓存,未重复上传' : '✅ 已上传')
2461
+ + '(' + Math.round((r.bytes ?? 0) / 1024) + 'KB'
2462
+ + (r.width ? ',' + r.width + '×' + r.height : '') + ')';
2463
+ return { ok: true, url: String(r.url), markdown, msg: head + '\n' + markdown };
2464
+ }
2465
+ if (action === 'list') {
2466
+ const r = await listMarkdownImages(target, String(args.cursor ?? ''));
2467
+ if (!r.ok)
2468
+ return { ok: false, msg: '查询失败: ' + String(r.msg || '') };
2469
+ const d = (r.data ?? {});
2470
+ const list = Array.isArray(d.images) ? d.images : [];
2471
+ const lines = list.slice(0, 20).map((x, i) => {
2472
+ const exp = x.expires_at ? ' | 到期 ' + new Date(Number(x.expires_at) * 1000).toISOString().slice(0, 10) : '';
2473
+ return i + 1 + '. ' + String(x.file_name || '?') + ' | ' + String(x.image_id || '') + exp;
2474
+ });
2475
+ const more = d.next_cursor ? '(还有下一页,cursor=' + d.next_cursor + ')' : '';
2476
+ return {
2477
+ ok: true,
2478
+ msg: '共 ' + String(d.total ?? list.length) + ' 张' + more + (lines.length ? '\n' + lines.join('\n') : '\n(列表为空)'),
2479
+ };
2480
+ }
2481
+ if (action === 'delete') {
2482
+ const id = String(args.image_id ?? '').trim();
2483
+ if (!id)
2484
+ return { ok: false, msg: 'delete 需要 image_id' };
2485
+ const r = await deleteMarkdownImage(target, id);
2486
+ return r.ok
2487
+ ? { ok: true, msg: '✅ 已删除 ' + id + '(配额已释放;已发出的消息不受影响)' }
2488
+ : { ok: false, msg: '删除失败: ' + String(r.msg || '') };
2489
+ }
2490
+ return { ok: false, msg: '未知 action: ' + action + '(可选 upload / list / delete)' };
2491
+ },
2492
+ });
2339
2493
  const recallBatchTool = defineTool({
2340
2494
  name: 'group_recall_batch',
2341
2495
  description: '群管理(写,危险): 批量撤回消息 + 可选批量禁言发送者。'
@@ -2760,6 +2914,7 @@ export async function apply(ctx) {
2760
2914
  { name: 'group_recall_batch', tool: recallBatchTool },
2761
2915
  { name: 'recall_keywords', tool: recallKeywordsTool },
2762
2916
  { name: 'group_kick', tool: kickMemberTool },
2917
+ { name: 'qq_material', tool: materialTool },
2763
2918
  { name: 'id_lookup', tool: idLookupTool },
2764
2919
  { name: 'session_list', tool: sessionListTool },
2765
2920
  { name: 'session_wake', tool: sessionWakeTool },
@@ -3717,6 +3872,11 @@ export function ensureExtToolsGlobal(globalCtx) {
3717
3872
  })();
3718
3873
  }
3719
3874
  export function ensureContextToolsGlobal(globalCtx) {
3875
+ // 2026-10-21 修(trace 实证):restrict 报 "names unknown global tools" => 我们注册的层不是「全局层」。
3876
+ // cordis 的 ctx.root 才是根作用域;拿不到就退回传入值(行为不变)。
3877
+ const rootOfGlobal = (globalCtx.root ?? globalCtx);
3878
+ if (rootOfGlobal !== globalCtx)
3879
+ traceToolReg('[ctxtool-reg] 用 root ctx 注册(不同于传入的 ctx)');
3720
3880
  try {
3721
3881
  if (!globalCtx || typeof globalCtx !== 'object')
3722
3882
  return;
@@ -3725,7 +3885,7 @@ export function ensureContextToolsGlobal(globalCtx) {
3725
3885
  // 表现就是"全局注册压根没发生"(trace 里只有别的报错、没有"全局注册 N 件")。
3726
3886
  // 正解:**用 cordis 的运行时取服务 `ctx.get('tools')`**(不需要 inject 声明)——
3727
3887
  // 本插件别处也是这个姿势(见 ext-capabilities 给扩展工具的 kernel 文档:`ctx.get('tools')`)。
3728
- const ctxObj = globalCtx;
3888
+ const ctxObj = rootOfGlobal;
3729
3889
  let toolsObj;
3730
3890
  try {
3731
3891
  toolsObj = ctxObj.tools;
@@ -3763,14 +3923,55 @@ export function ensureContextToolsGlobal(globalCtx) {
3763
3923
  }
3764
3924
  /** 每个 agent 作用域当前的可见性状态(幂等用:状态没变就不动) */
3765
3925
  const ctxRestrictState = new WeakMap();
3926
+ /** 释放 restriction 的唯一正确姿势(2026-10-21,抄 dsh-computer-use/internal/schema-visibility.mjs):
3927
+ * cordis 的 `ctx.effect(fn)` 返回的是**函数**(`Disposable<T> = () => T`),
3928
+ * 对函数调 `.dispose?.()` 会**静默变成空操作** —— 限制挂上去、再也没摘下来。
3929
+ * 所以函数优先,`.dispose()` 只作兼容。
3930
+ */
3931
+ function liftRestriction(disposable) {
3932
+ if (typeof disposable === 'function') {
3933
+ try {
3934
+ disposable();
3935
+ }
3936
+ catch { /* ignore */ }
3937
+ return;
3938
+ }
3939
+ const d = disposable;
3940
+ if (d && typeof d.dispose === 'function') {
3941
+ try {
3942
+ d.dispose();
3943
+ }
3944
+ catch { /* ignore */ }
3945
+ }
3946
+ }
3947
+ /** 取某作用域的 tools 服务:`ctx.tools` 在未声明 inject 时会抛,退化到 cordis 运行时 `ctx.get('tools')`。 */
3948
+ function toolsOfScope(agentCtx) {
3949
+ const c = agentCtx;
3950
+ try {
3951
+ const t = c?.tools;
3952
+ if (t && typeof t === 'object')
3953
+ return t;
3954
+ }
3955
+ catch { /* 未 inject 时读 ctx.tools 会抛 */ }
3956
+ try {
3957
+ const t = c?.get?.('tools');
3958
+ if (t && typeof t === 'object')
3959
+ return t;
3960
+ }
3961
+ catch { /* ignore */ }
3962
+ return undefined;
3963
+ }
3766
3964
  /**
3767
- * 按会话**实时剪枝**三个上下文工具的可见性(2026-10-09,主人要的"实时变更")。
3965
+ * 按会话**实时剪枝**三个上下文工具的可见性(2026-10-09 起;2026-10-21 按 `dsh-computer-use` 的实测修)。
3768
3966
  *
3769
3967
  * 语义:**开了「无上下文模式 + 智能判断」**的会话 → 看得见那三个工具;
3770
3968
  * 否则该会话**连工具名都看不到**(模型清单里没有 = 零 token),而**注册表纹丝不动**。
3771
- * 面板一改开关 → 下一次 pre-step(下一回合/下一 step)即生效;撤销限制是立即的。
3969
+ * 面板一改开关 → 下一次 pre-step(下一回合 / 下一 step)即生效。
3772
3970
  *
3773
- * 幂等:同一作用域状态没变化就直接 return,不重复设/撤 restriction(避免无谓的变更通知与抖动)。
3971
+ * 2026-10-21 修两处(对齐 `dsh-plugins/dsh-computer-use/internal/schema-visibility.mjs`):
3972
+ * ① `ctx.tools` 在未 inject 时会抛 ⇒ 必须退化到 `ctx.get('tools')`,否则"剪枝"整段被 catch 吞掉;
3973
+ * ② `restrict()` 的返回值是**函数**,释放必须走 {@link liftRestriction}。
3974
+ * 幂等:同一作用域状态没变化就直接 return,不重复设/撤 restriction(避免无谓变更通知与抖动)。
3774
3975
  */
3775
3976
  export function restrictContextTools(agentCtx, shouldHave, env) {
3776
3977
  try {
@@ -3784,27 +3985,34 @@ export function restrictContextTools(agentCtx, shouldHave, env) {
3784
3985
  const st = ctxRestrictState.get(key);
3785
3986
  if (st && st.shouldHave === shouldHave)
3786
3987
  return; // ← 状态一致:不动(这是"不抖"的关键)
3787
- if (st?.dispose) {
3788
- try {
3789
- st.dispose();
3790
- }
3791
- catch { /* ignore */ }
3792
- }
3793
- const toolsObj = agentCtx.tools;
3794
- const restrict = toolsObj?.restrict;
3988
+ if (st?.dispose)
3989
+ liftRestriction(st.dispose);
3795
3990
  let dispose;
3796
- if (!shouldHave && typeof restrict === 'function') {
3991
+ if (!shouldHave) {
3992
+ const toolsObj = toolsOfScope(agentCtx);
3993
+ const restrict = toolsObj?.restrict;
3994
+ if (typeof restrict !== 'function') {
3995
+ traceToolReg('[ctxtool-reg] 剪枝失败: 拿不到 tools.restrict(ctx.tools 与 ctx.get("tools") 都取不到)');
3996
+ ctxRestrictState.set(key, { shouldHave });
3997
+ return;
3998
+ }
3797
3999
  try {
3798
4000
  const d = restrict.call(toolsObj, { deny: [...CTX_TOOL_NAMES] });
3799
4001
  if (typeof d === 'function')
3800
4002
  dispose = d;
4003
+ else if (d && typeof d.dispose === 'function') {
4004
+ const dd = d;
4005
+ dispose = () => { dd.dispose(); };
4006
+ }
4007
+ traceToolReg('[ctxtool-reg] 剪枝已挂 deny=' + CTX_TOOL_NAMES.join(',') + ' 返回形态=' + (typeof d));
3801
4008
  }
3802
4009
  catch (e) {
3803
- traceToolReg('[ctxtool-reg] restrict 失败: ' + String(e?.message ?? e).slice(0, 160));
4010
+ traceToolReg('[ctxtool-reg] restrict 抛错: ' + String(e?.message ?? e).slice(0, 160));
3804
4011
  }
3805
4012
  }
3806
4013
  ctxRestrictState.set(key, dispose ? { shouldHave, dispose } : { shouldHave });
3807
- traceToolReg(`[ctxtool-reg] ${shouldHave ? '放开(本会话继承全局三个工具)' : '剪枝(本会话看不到三个工具)'} dataRoot=${String(env?.dataRoot ?? '')} sk=${String(env?.sessionKey ?? '')}`);
4014
+ traceToolReg(`[ctxtool-reg] ${shouldHave ? '放开(本会话继承全局三个工具)' : '剪枝(本会话看不到三个工具)'}`
4015
+ + ` dispose=${dispose ? '有' : '无'} dataRoot=${String(env?.dataRoot ?? '')} sk=${String(env?.sessionKey ?? '')}`);
3808
4016
  }
3809
4017
  catch (e) {
3810
4018
  traceToolReg('[ctxtool-reg] 剪枝异常: ' + String(e?.message ?? e).slice(0, 160));