dsh-plugin-tool-management 0.9.0 → 0.10.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.
@@ -33,7 +33,42 @@
33
33
  // - 某域曾经注入过、现在没有内容(域被关掉或正文清空)→ 发一条该域的「已清空」,
34
34
  // 免得旧目录继续被当成现状。
35
35
  //
36
+ // 2026-09-17 第二版加的两件事:
37
+ // - **按深度抑制人设目录**:宿主平面注册意味着子代理派生的会话也收到五域。而人设目录
38
+ // 要不要出现在某个深度的会话里,由人设的 `catalogDepth`(默认 1 = 只在顶层注入)决定;
39
+ // 判定放在域声明的 `applicableTo` 上,通道只负责问一句 —— "哪个域对哪类会话不成立"
40
+ // 是域的语义,不是通道的机制。
41
+ // ⚠️ 2026-09-17 方案 A 纠正:这**不是**"子会话不能委派"。官方 `dsh-tool-subagent`
42
+ // 默认 `maxDepth: 3`,provider 只在传了该值时才校验 —— 子代理本来就能继续嵌套
43
+ // (用户实测确认)。这个抑制只是让常驻目录不进入子会话、减少噪声;委派能力由官方
44
+ // 决定,本插件不干预(我们也不再传 `maxDepth`)。
45
+ // - **采纳遥测**:注入只解决"到没到",不解决"用没用"。本模块按域统计
46
+ // 「投递 N 次 / 调用 M 次 / 调用时正文在眼前 K 次」,工具注册处调 `noteToolUse` 记账。
47
+ // 没有这三个数,"模型忽略了注入内容"只是一句感觉,改了措辞也无从验证。
48
+ //
49
+ // 2026-09-17 第三版(两件事):
50
+ // - **界面契约对齐**:`source` 上补齐宿主给界面的结构化字段 —— snapshot 形态的 `sections`
51
+ // 一直有;instructions 形态现在带 `changes`(+ 首帧 `baseline`,来源文件由域声明的 `files`
52
+ // 提供),界面从"一坨原文"升级成官方 AGENTS.md 同款「文件 + 已载入/已更新」。这些字段
53
+ // **只给界面看**:模型侧 content 不受影响,去重比对的也还是 content。
54
+ // - **instructions 形态不再裸送**:模型侧正文改成与其他四域同款的 `<system-reminder>` 框架
55
+ // (此前"原样送"的理由是"对齐官方",与官方实际行为不符 —— 官方那条是包框架的;详见
56
+ // `renderDomainText` 的第三版说明)。
57
+ //(catalog 形态**有意不带** `entries`:界面的条目渲染会整段替换正文,而我们的目录正文带着
58
+ // 框架句、未运行标记与"N 个未列出"这类补充行,给条目反而显示得更少。)
59
+ //
60
+ // 2026-09-18 第四版(用户:注入仍不能很好提醒模型去主动使用 + 该用 md 排版突出重点信息):
61
+ // - **框架改 md 四级结构**:`## 标题`(标签)+ **加粗动作句**(决策点线索)+ 补充动作行
62
+ // (工具名 / 触发条件)+ 权威声明句(取代哪一份)。写法逐条对照 Claude Code 与 Codex CLI
63
+ // 的官方注入(理由见 `DOMAIN_FRAME`)。
64
+ // - **正文也进标记**:此前 `<system-reminder>` 只包引导语、正文裸在外面;官方两条注入行
65
+ // (skill-catalog / agent-instructions)都是整条包住的。正文里混着用户自由文本
66
+ // (AGENTS.md / 记忆 / 备注),来源标记不能只盖住我们写的那一句。
67
+ // - **闭合标记转义**:正文里的 `</system-reminder>` 会被拆开(照抄官方
68
+ // `escapeInstructionFrameBody`),否则用户手写一个闭合标记就能让框架提前结束。
69
+ //
36
70
  // 注入永远不能让这一步失败:任何异常都在监听器里吞掉、原样返回 decision。
71
+ // 遥测同理(`noteToolUse` 自己吞异常):它绝不能影响工具本身。
37
72
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
38
73
  import { SessionSeq } from '@deepseek-ai/dsh-session';
39
74
  /**
@@ -54,6 +89,72 @@ export const INJECT_KIND_OF = {
54
89
  subagents: 'subagent-manager-catalog',
55
90
  prompt: 'prompt-manager-catalog',
56
91
  };
92
+ /**
93
+ * 域 → 该域在模型工具表里的**工具名前缀**(采纳遥测的唯一映射)。
94
+ *
95
+ * 为什么要这张表:注入只解决"内容到没到",不解决"模型用没用"。「注入实况」此前
96
+ * 只能答前半句 —— 勾了开关、正文也在上下文里,但模型从头到尾没伸手,界面上和
97
+ * 「用了」长得一模一样。有了映射,就能把「投递 N 次 / 调用 M 次」并排摆出来,
98
+ * 措辞与形式的调整才有依据(否则改文案就是猜)。
99
+ *
100
+ * 前缀而不是精确名:`memory_manager_*` 有 list/read/write/delete 等,任何一个都
101
+ * 说明模型确实在读这一域。改工具名等于换身份,这五个字符串是稳定契约。
102
+ */
103
+ export const DOMAIN_TOOL_PREFIX = {
104
+ memory: 'memory_manager_',
105
+ mcp: 'mcp_manager_',
106
+ skills: 'skill_manager_',
107
+ subagents: 'subagent_manager_',
108
+ prompt: 'prompt_manager_',
109
+ };
110
+ /** 工具名 → 域(`undefined` = 不是本插件的域工具)。纯函数,便于单独推理。 */
111
+ export function domainOfTool(toolName) {
112
+ const name = String(toolName ?? '');
113
+ for (const key of INJECT_DOMAIN_KEYS) {
114
+ if (name.startsWith(DOMAIN_TOOL_PREFIX[key]))
115
+ return key;
116
+ }
117
+ return undefined;
118
+ }
119
+ /**
120
+ * 这个 agent 在委派链上的**深度**(0 = 顶层会话,1 = 子会话,2 = 孙会话…)。
121
+ *
122
+ * 为什么需要它:`agent/pre-step` 挂在宿主平面,天然覆盖所有 agent(含子智能体)——
123
+ * 这是当初选这条通道的代价。而人设目录该不该注入,取决于会话**有多深**(人设的
124
+ * `catalogDepth` 决定"目录注入到几层",默认 1 = 只在顶层),那是个关于深度的判断,
125
+ * 不是一个布尔的身份标签。
126
+ *
127
+ * 口径照抄官方 `delegationDepthOf()`(`dsh-subagent/lib/index.js:144`):持久化头
128
+ * `session.header.delegationDepth` 与运行期选项 `agent.options.subagentDepth` 取较大者
129
+ * —— 单看头会让"恢复的子会话"重新变成顶层(官方注释明说这是它要防的事)。
130
+ * `header.origin === 'subagent'` 是"是子会话"的硬信号,深度至少算 1(头里没记深度时
131
+ * 不能退回 0)。任一探针取不到或不是有限数都当它缺席:宁可把深度算浅一次(多注入一域),
132
+ * 也不要把正常会话误判成子会话而静默少一域。
133
+ */
134
+ export function subagentDepthOf(agent) {
135
+ try {
136
+ const a = agent;
137
+ const header = a?.session?.header;
138
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
139
+ const persisted = header !== undefined && header !== null ? finite(header.delegationDepth) : 0;
140
+ const runtime = finite(a?.options?.subagentDepth);
141
+ const floor = header !== undefined && header !== null && header.origin === 'subagent' ? 1 : 0;
142
+ return Math.max(floor, persisted, runtime);
143
+ }
144
+ catch {
145
+ return 0;
146
+ }
147
+ }
148
+ /**
149
+ * 这个 agent 是不是**子会话**(子代理派生的会话)。深度判定的布尔投影。
150
+ *
151
+ * ⚠️ 生产代码现在只用 `subagentDepthOf` —— 判据是"目录该不该注入到这么深的会话",
152
+ * 需要的是**深度数字**,不是一个布尔身份(2026-09-17 方案 A:委派可行性由官方决定,
153
+ * 与"是不是子会话"无关)。这个投影留给真正需要布尔判断的场合,以及测试。
154
+ */
155
+ export function isSubagentSession(agent) {
156
+ return subagentDepthOf(agent) >= 1;
157
+ }
57
158
  /**
58
159
  * 「官方已经送得到就别再送」的域 → 对应预设事实的键。
59
160
  *
@@ -86,17 +187,20 @@ export function normalizeInjectSettings(raw) {
86
187
  };
87
188
  }
88
189
  /**
89
- * 纯函数:按设置 + 预设事实挑出本次要注入的域。
190
+ * 纯函数:按设置 + 预设事实 + 目标 agent 挑出本次要注入的域。
90
191
  *
91
- * 规则(设计 2026-09-16):
192
+ * 规则(设计 2026-09-16;目录注入深度 2026-09-17):
92
193
  * - 域开关关掉的、文本为空的,都不进;
93
194
  * - **压制型预设**且没开「仍然注入」→ 一个都不进(跟随预设);
195
+ * - 域声明 `applicableTo` 说不成立的域不进(当前只有人设目录:会话深度超出了人设的
196
+ * `catalogDepth`,默认 1 = 只在顶层注入。**这不是"子会话不能委派"** —— 委派由官方
197
+ * 决定,见 service.ts 里 `catalogDepth` 的注释);
94
198
  * - 有官方载体的两个域(提示词 / 技能目录):预设挂得到官方那条行时不进
95
199
  * —— 官方自己会送,再注入一遍只会重复;预设事实读不到(`undefined`)时按"已承载"处理
96
200
  * (宁可与现状一致,也不制造重复);
97
201
  * - 同一段文本出现两次只保留前一个(跨域保险)。
98
202
  */
99
- export function selectInjections(domains, settings, facts) {
203
+ export function selectInjections(domains, settings, facts, agent) {
100
204
  const suppressing = facts !== undefined && facts.suppressing;
101
205
  if (suppressing && !settings.underSuppressingPresets)
102
206
  return [];
@@ -105,13 +209,19 @@ export function selectInjections(domains, settings, facts) {
105
209
  for (const domain of domains) {
106
210
  if (settings.domains[domain.key] !== true)
107
211
  continue;
212
+ // 域自己不成立就不发(如人设目录的 catalogDepth 没覆盖到本会话的深度)。**这里没有开关,是刻意的**:
213
+ // 曾经有个 `suppressInSubagentSessions` 想让这一步可选,但它是个空开关 —— 域不成立时
214
+ // 正文自己也被过滤成空的(见 `subagents/catalog.ts` 的按深度过滤),两道闸条件相同,
215
+ // 开关只控制得了其中一道。留一个点了没反应的勾选框比没有更糟,所以删掉(2026-09-17)。
216
+ if (domain.applicableTo !== undefined && !domain.applicableTo(agent))
217
+ continue;
108
218
  const carrier = CARRIER_FACT_OF[domain.key];
109
219
  // 官方载体已挂(或读不到预设、只能按已挂算)→ 不重复送;只有明确"没挂"才兜底。
110
220
  if (carrier !== undefined && facts?.[carrier] !== false)
111
221
  continue;
112
222
  let text = '';
113
223
  try {
114
- text = String(domain.text() ?? '');
224
+ text = String(domain.text(agent) ?? '');
115
225
  }
116
226
  catch {
117
227
  text = '';
@@ -127,27 +237,141 @@ export function selectInjections(domains, settings, facts) {
127
237
  return out;
128
238
  }
129
239
  /**
130
- * 一条注入消息的正文(纯函数):instructions 域原样送(对齐官方 AGENTS.md 那条行),其余带一行引导语。
240
+ * 纯函数:算出某一步每个域的原因(`selectInjections` 的镜像,供「注入实况」解释状态)。
241
+ *
242
+ * 与 `selectInjections` 分开写是为了让两边各自可读:一个是"发什么",一个是"为什么"。
243
+ * 口径必须一致 —— 否则界面会把"子会话抑制"报成"未投递",而界面的既有口径是
244
+ * 「琥珀只留给'该投却没投'」(把用户自己的选择读成故障,正是这条口径要避免的事)。
245
+ *
246
+ * ⚠️ 这里**故意不镜像** `selectInjections` 开头那条"压制型预设一个都不进"的提前返回:
247
+ * 该分支下本函数仍按各域自身的原因作答,于是压制型预设 + 没开「仍然注入」时,
248
+ * 界面看到的是"未投递"(琥珀)而不是"已关闭"(灰)。这是 0.9.1 的既有行为,本次未改,
249
+ * 改动它属于另一个话题("跟随预设"到底该报成故障还是报成选择)。
250
+ */
251
+ export function explainInjections(domains, settings, facts, agent) {
252
+ const reasons = {};
253
+ for (const domain of domains) {
254
+ if (settings.domains[domain.key] !== true) {
255
+ reasons[domain.key] = 'off';
256
+ continue;
257
+ }
258
+ if (domain.applicableTo !== undefined && !domain.applicableTo(agent)) {
259
+ reasons[domain.key] = 'child';
260
+ continue;
261
+ }
262
+ const carrier = CARRIER_FACT_OF[domain.key];
263
+ if (carrier !== undefined && facts?.[carrier] !== false) {
264
+ reasons[domain.key] = 'official';
265
+ continue;
266
+ }
267
+ let text = '';
268
+ try {
269
+ text = String(domain.text(agent) ?? '');
270
+ }
271
+ catch {
272
+ text = '';
273
+ }
274
+ reasons[domain.key] = text.trim() === '' ? 'empty' : 'sent';
275
+ }
276
+ return reasons;
277
+ }
278
+ const DOMAIN_FRAME = {
279
+ memory: {
280
+ title: '本机当前的场景和记忆',
281
+ cue: '在回答涉及本机的事之前,先核对这里。',
282
+ supersede: '本份记忆取代本次会话中更早注入的同类记忆。',
283
+ },
284
+ mcp: {
285
+ title: '本机 MCP 服务器的当前状态',
286
+ cue: '要用某个 MCP 工具前,先在这里确认这台服务器在不在、开没开。',
287
+ how: '工具名是 `mcp__<服务器>__<工具>`;带「用户提示:」的行是用户写给这台服务器的决策提示,选服务器之前先看一眼。',
288
+ supersede: '本份状态取代本次会话中更早注入的同类状态。',
289
+ },
290
+ skills: {
291
+ title: '本机技能目录',
292
+ cue: '需要某项能力时,先在这里找。',
293
+ how: '本预设没有官方 `skill` 工具:要正文用 `skill_manager_list` 取源文件路径再读;目录只有摘要,读完再照做。',
294
+ supersede: '本份目录取代本次会话中更早注入的同类目录;只列当前可调用的技能。',
295
+ },
296
+ subagents: {
297
+ title: '可委派的子智能体',
298
+ cue: '在决定自己做还是委派之前,先在这里选人设。',
299
+ // 分界规则(2026-09-17 方案 C,本机实测 session-ee722e23 逼出来的):官方那两个
300
+ // 委派工具(`subagent` / `subagent_fork`)不带人设,而此前没有任何一句话说明何时该
301
+ // 用谁 —— 模型在"审查刚读过的 README"时选了 `subagent_fork`(fork 能继承已读内容、
302
+ // 省一次复述)。现在人设通道也有 `inherit`(同一套 fork 机制),分界只剩"要不要后台跑"。
303
+ how: '贴合人设的任务一律用 `subagent_manager_run`(要它看到本次会话就开 `inherit`);官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。',
304
+ supersede: '本份目录取代本次会话中更早注入的同类目录。',
305
+ },
306
+ prompt: {
307
+ title: '本机提示词',
308
+ cue: '动手之前先按它对齐,与它冲突的默认做法一律让位。',
309
+ supersede: '本份提示词取代本次会话中更早注入的同类提示词。',
310
+ },
311
+ };
312
+ /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
313
+ const fallbackFrame = (label) => ({
314
+ title: `本机的${label}`,
315
+ cue: `需要这台机器的${label}时,先核对这里。`,
316
+ supersede: '本份内容取代本次会话中更早注入的同类内容。',
317
+ });
318
+ const domainFrame = (key, label) => DOMAIN_FRAME[key] ?? fallbackFrame(label);
319
+ /**
320
+ * 框架的伪 XML 标记。与官方两条注入行同款:`dsh-tool-skill` 与 `dsh-agent-instructions`
321
+ * 都把**整条正文**包在里面(不是只包引导语)。Claude Code 那边把这对标记叫"可依赖的
322
+ * 判别符"(`messages.ts:1797` 的 `ensureSystemReminderWrap` 保证任何注入文本都不落在外面):
323
+ * 模型据此把这段读成系统给的上下文,而不是用户刚打的字 —— 本插件的正文里混着用户写的
324
+ * 自由文本(AGENTS.md / 记忆 / 备注),这层来源标记尤其不能少。
325
+ */
326
+ const FRAME_OPEN = '<system-reminder>';
327
+ const FRAME_CLOSE = '</system-reminder>';
328
+ /**
329
+ * 正文里的 `</system-reminder>` 拆掉闭合形态(写成 `<\/system-reminder>`)。
330
+ *
331
+ * 正文含用户自由文本(AGENTS.md、记忆、MCP 备注、技能描述),一句手写的闭合标记就能让框架
332
+ * 提前结束,其后的内容读起来像用户当场说的话。照抄官方 `escapeInstructionFrameBody` 的实现
333
+ * (`dsh-agent-instructions/lib/index.js:128`,同样是替换成带反斜杠的形态 —— 模型看到的
334
+ * 字面量不变,标记不再闭合)。
335
+ */
336
+ export function escapeFrameBody(body) {
337
+ return body.replaceAll(FRAME_CLOSE, '<\\/system-reminder>');
338
+ }
339
+ /**
340
+ * 一条注入消息的正文(纯函数):`<system-reminder>` 里 = 框架(标题 + 动作 + 补充 + 权威
341
+ * 声明)+ 空行 + 域正文。**五个域一律带框架**,没有例外。
131
342
  *
132
- * 引导语只有**一句话**(用户裁定 2026-09-16:五个域各发各的以后,原来那四行说明在每条消息里
133
- * 重复一遍太冗余):说清"这是什么 + 取代谁"就够 —— 域的名字、怎么查细节(`*_manager_list`
134
- * 就在模型自己的工具表里)都不必在这里再说一遍。
343
+ * 历史(每一版都是被具体毛病逼出来的,别把结论当套话读):
344
+ * - 第一版(2026-09-17 前):五域共用「以下是本机插件的X(取代…)。」——「本机插件」是实现
345
+ * 细节;通篇没有动作;五条消息句式一模一样,雷同的套话退化成背景噪声。
346
+ * - 第二版(2026-09-17):动作前置(`**{线索}**:以下是{域}(取代…)。{补充}`),但只改了
347
+ * 用户圈定的两域(记忆 / 子智能体),MCP、技能、提示词继续走老模板。
348
+ * - 第三版(2026-09-17):instructions 形态不再裸送 —— 此前"原样送"的理由是"对齐官方
349
+ * AGENTS.md 那条行",与官方实际行为不符(官方那条是包 `<system-reminder>` 的,含一句
350
+ * "这些工作区指令可作参考……"。真正的问题在极简类预设:官方通道被压掉后本插件是唯一
351
+ * 承载者,裸文本没有任何标记,而提示词会因场景切换而变化、新旧两份效力相同、没有判据。
352
+ * 只借官方**包框架**的形式,**不抄**它那句把用户规则降成"仅供参考"的措辞。
353
+ * - 第四版(2026-09-18,本条):**动作仍然不够显眼**(用户:「还是不能很好提醒模型去主动
354
+ * 使用」),且整条消息该按 md 排版突出关键信息。改成四级结构:`##` 标题当标签、
355
+ * 加粗动作句单独一行、补充动作给工具名、权威声明单独成句(逐条理由见 `DOMAIN_FRAME`)。
356
+ * 同时把**正文也包进标记里**(此前只有引导语在标记内、正文裸奔)—— 官方两条注入行都是
357
+ * 整条包住的,正文里的用户自由文本更需要这层来源标记。
135
358
  */
136
359
  export function renderDomainText(section) {
137
- if (section.form === 'instructions')
138
- return section.text;
139
- return [
140
- '<system-reminder>',
141
- `以下是本机插件的${section.label}(取代本次会话中更早的同类内容)。`,
142
- '</system-reminder>',
143
- ].join('\n') + '\n\n' + section.text;
360
+ const frame = domainFrame(section.key, section.label);
361
+ const lines = [FRAME_OPEN, `## ${frame.title}`, `**${frame.cue}**`];
362
+ if (frame.how !== undefined)
363
+ lines.push(frame.how);
364
+ lines.push(frame.supersede, '', escapeFrameBody(section.text), FRAME_CLOSE);
365
+ return lines.join('\n');
144
366
  }
145
367
  /** 「已清空」通知正文:某个域曾经注入过、现在没有内容时发一条(纯函数,测试用)。 */
146
368
  export function clearedDomainText(key, label) {
369
+ const frame = domainFrame(key, label);
147
370
  return [
148
- '<system-reminder>',
149
- `dsh-plugin-tool-management:本插件的${label}已清空,本次会话中此前注入的同类内容不再有效。`,
150
- '</system-reminder>',
371
+ FRAME_OPEN,
372
+ `## ${frame.title}`,
373
+ '**已清空** —— 本次会话中此前注入的同类内容不再有效。',
374
+ FRAME_CLOSE,
151
375
  ].join('\n');
152
376
  }
153
377
  /** 取一条消息的正文(只认单 text 块;形如官方 RuntimeContextProjection.textOf)。 */
@@ -158,20 +382,49 @@ function textOf(message) {
158
382
  const block = content[0];
159
383
  return block && block.type === 'text' && typeof block.text === 'string' ? block.text : undefined;
160
384
  }
385
+ /** 本插件自己的五个注入 kind(去重比对只用这一份 —— 绝不能让官方正文影响"发不发"的判断)。 */
386
+ const PLUGIN_KINDS = new Map(INJECT_DOMAIN_KEYS.map((key) => [INJECT_KIND_OF[key], { key, official: false }]));
387
+ /**
388
+ * 官方载体消息的 kind → 本插件域:技能目录(`@deepseek-ai/dsh-tool-skill`)与
389
+ * AGENTS.md(`@deepseek-ai/dsh-agent-instructions`)。仅用于「注入实况」展示 ——
390
+ * 标准类预设下这两个域由官方在送,它同样是"模型看到的内容",体积要算官方那份。
391
+ * 字符串取自官方包,是宿主侧的稳定契约。
392
+ */
393
+ const OFFICIAL_KIND_OF = {
394
+ 'skill-catalog': 'skills',
395
+ 'agent-instructions': 'prompt',
396
+ };
161
397
  /**
162
- * 会话**可见表面上**每个域最新一条己方注入的正文。
398
+ * 用户**明确关掉**的域 → 该域对应的官方消息 kind(这些 kind 的消息这一步不放行)。
163
399
  *
164
- * 可见表面 = `session.surface.nodes`(dsh-session 的公开面,官方 skill-catalog 与
165
- * agent-instructions 都直接扫它):一条注入若已被压缩移出表面,说明它不在模型上下文里
166
- * → 该域不在 map 里 → 该重发。表面接口整个缺失时退回扫事件流(按"仍可见"处理,
167
- * 宁可与现状一致,也不制造每步重发)。
400
+ * 交互事实(用户 2026-09-17 指出):技能与提示词两域在标准类预设下由官方送(本插件让位),
401
+ * 于是"取消勾选"只停掉了本插件自己,官方那条照样进上下文 —— 用户看到的是"关了没用"。
402
+ * 本函数给出需要**连带拦下**的官方 kind:只有"官方自己会送同份内容"的两个域有这一项;
403
+ * MCP / 记忆 / 人设目录官方不送,没有可拦的对象(它们的开关本来就完全生效)。
168
404
  */
169
- function newestDomainTexts(agent) {
405
+ export function officialKindsToSuppress(settings) {
406
+ const out = new Set();
407
+ for (const [kind, key] of Object.entries(OFFICIAL_KIND_OF)) {
408
+ if (settings.domains[key] === false)
409
+ out.add(kind);
410
+ }
411
+ return out;
412
+ }
413
+ /** 一条消息的来源 kind(读不到 = `undefined`)。 */
414
+ function kindOfMessage(message) {
415
+ const source = message?.source;
416
+ return source && typeof source.kind === 'string' ? source.kind : undefined;
417
+ }
418
+ /** 实况展示用的 kind 表:本插件的五条 + 官方两条。 */
419
+ const LIVE_KINDS = new Map([
420
+ ...PLUGIN_KINDS,
421
+ ...Object.entries(OFFICIAL_KIND_OF).map(([kind, key]) => [kind, { key, official: true }]),
422
+ ]);
423
+ function newestDomainTexts(agent, kinds) {
170
424
  const out = new Map();
171
425
  const session = agent?.session;
172
426
  if (!session || typeof session.seq !== 'number' || typeof session.eventAt !== 'function')
173
427
  return out;
174
- const keyOfKind = new Map(INJECT_DOMAIN_KEYS.map((key) => [INJECT_KIND_OF[key], key]));
175
428
  const scan = (seq) => {
176
429
  let event;
177
430
  try {
@@ -183,13 +436,13 @@ function newestDomainTexts(agent) {
183
436
  if (!event || event.type !== 'user/message')
184
437
  return false;
185
438
  const source = event.data && event.data.source;
186
- const key = source && typeof source.kind === 'string' ? keyOfKind.get(source.kind) : undefined;
187
- if (key === undefined || out.has(key))
439
+ const mapping = source && typeof source.kind === 'string' ? kinds.get(source.kind) : undefined;
440
+ if (mapping === undefined || out.has(mapping.key))
188
441
  return false;
189
442
  const text = textOf(event.data);
190
443
  if (text === undefined)
191
444
  return false;
192
- out.set(key, text);
445
+ out.set(mapping.key, { text, form: source && typeof source.form === 'string' ? source.form : '', official: mapping.official });
193
446
  return out.size === INJECT_DOMAIN_KEYS.length;
194
447
  };
195
448
  const nodes = session.surface && Array.isArray(session.surface.nodes) ? session.surface.nodes : undefined;
@@ -211,11 +464,21 @@ function newestDomainTexts(agent) {
211
464
  break;
212
465
  return out;
213
466
  }
467
+ const bytesOf = (text) => {
468
+ try {
469
+ return Buffer.byteLength(text, 'utf8');
470
+ }
471
+ catch {
472
+ return text.length;
473
+ }
474
+ };
214
475
  /**
215
- * 注册 `agent/pre-step` 注入监听;返回清理函数。
476
+ * 注册 `agent/pre-step` 注入监听;返回清理函数与采纳遥测入口。
216
477
  *
217
478
  * 宿主平面注册即可覆盖所有 agent(含子智能体、含任何预设)——dsh-scope 的
218
479
  * `scopeTarget` 过滤对没有 scope 标记的 ctx 直接放行,官方 time-context 就是这么挂的。
480
+ * "覆盖到子智能体"这件事本身是**特性**(子会话同样需要记忆与提示词),只有人设目录
481
+ * 那一域对它不成立,由域声明的 `applicableTo` 单独挡掉 —— 通道不替域做决定。
219
482
  */
220
483
  export function createContextInjector(deps) {
221
484
  const log = (message) => {
@@ -224,9 +487,86 @@ export function createContextInjector(deps) {
224
487
  }
225
488
  catch { /* ignore */ }
226
489
  };
490
+ // 最近活跃的会话(WeakRef:诊断用,不阻止会话被回收)。每次 pre-step 都刷新 ——
491
+ // 包括"空 turn 提前返回"和"这一步没有内容可发"的分支,页面才能如实说"没投过"。
492
+ let lastAgent = null;
493
+ const delivered = { count: 0, lastAt: null, byDomain: {} };
494
+ // 采纳遥测:每个域一份计数 + 全局观测面。
495
+ const adoption = {};
496
+ for (const key of INJECT_DOMAIN_KEYS)
497
+ adoption[key] = { injected: 0, used: 0, adopted: 0, lastUsedAt: null };
498
+ const observed = { toolCalls: 0, lastAt: null };
499
+ // 每个 agent 最近一步"在上下文里"的域集合 —— 采纳判定要回答的是"调用发生时正文在不在眼前"。
500
+ // WeakMap:不阻止会话被回收(与 lastAgent 同一考虑)。
501
+ const liveDomainsByAgent = new WeakMap();
502
+ // 最近一步里"每个域为什么发/不发"(官方载体 / 开关关 / 空 / 子会话不适用 / 本插件负责)。
503
+ // 只在没有可见注入时用来解释状态 —— 标准类预设下技能与提示词由官方在送,
504
+ // 插件的实况若只说"未投递",读起来像出了问题。
505
+ let lastReasons = {};
506
+ const live = () => {
507
+ let agent;
508
+ try {
509
+ agent = lastAgent ? lastAgent.deref() : undefined;
510
+ }
511
+ catch {
512
+ agent = undefined;
513
+ }
514
+ const visible = agent === undefined
515
+ ? new Map()
516
+ : newestDomainTexts(agent, LIVE_KINDS);
517
+ const labelOf = new Map(deps.domains().map((domain) => [domain.key, domain.label]));
518
+ const rows = INJECT_DOMAIN_KEYS.map((key) => {
519
+ const entry = visible.get(key);
520
+ const reason = lastReasons[key];
521
+ const state = agent === undefined
522
+ ? 'unknown'
523
+ : entry !== undefined
524
+ // 官方载体发的那条也算"模型看到的内容"(只是不是本插件送的);
525
+ // 本插件发过又清空的,报「已清空」。
526
+ ? (entry.official ? 'official' : entry.form === 'notice' ? 'cleared' : 'in-context')
527
+ // 没在上下文里:用最近一步的原因解释;`sent`(该发)却没看到 = 压缩后还没补发。
528
+ : reason === 'off' ? 'off' : reason === 'official' ? 'official' : reason === 'empty' ? 'empty' : reason === 'child' ? 'child' : 'absent';
529
+ const text = (state === 'in-context' || state === 'official') && entry !== undefined ? entry.text : '';
530
+ return {
531
+ key,
532
+ label: labelOf.get(key) ?? key,
533
+ kind: INJECT_KIND_OF[key],
534
+ state,
535
+ bytes: bytesOf(text),
536
+ text,
537
+ adoption: { ...adoption[key] },
538
+ };
539
+ });
540
+ return {
541
+ hasAgent: agent !== undefined,
542
+ delivered: { count: delivered.count, lastAt: delivered.lastAt, byDomain: { ...delivered.byDomain } },
543
+ observed: { toolCalls: observed.toolCalls, lastAt: observed.lastAt },
544
+ domains: rows,
545
+ };
546
+ };
547
+ const noteToolUse = (toolName, agent) => {
548
+ try {
549
+ const key = domainOfTool(toolName);
550
+ if (key === undefined)
551
+ return;
552
+ const at = Date.now();
553
+ observed.toolCalls += 1;
554
+ observed.lastAt = at;
555
+ const row = adoption[key];
556
+ row.used += 1;
557
+ row.lastUsedAt = at;
558
+ // 采纳判定:调用发生时该域正文正在这个会话的上下文里。
559
+ if (typeof agent === 'object' && agent !== null) {
560
+ const liveKeys = liveDomainsByAgent.get(agent);
561
+ if (liveKeys !== undefined && liveKeys.has(key))
562
+ row.adopted += 1;
563
+ }
564
+ }
565
+ catch { /* 遥测绝不能影响工具本身 */ }
566
+ };
227
567
  const ctx = deps.ctx;
228
568
  if (!ctx || typeof ctx.on !== 'function')
229
- return { dispose: () => { } };
569
+ return { dispose: () => { }, live, noteToolUse };
230
570
  const stop = ctx.on('agent/pre-step', async (payload, next) => {
231
571
  const decision = await next();
232
572
  try {
@@ -235,22 +575,66 @@ export function createContextInjector(deps) {
235
575
  const agent = payload && payload.agent;
236
576
  if (!agent)
237
577
  return decision;
578
+ if (typeof agent === 'object') {
579
+ try {
580
+ lastAgent = new WeakRef(agent);
581
+ }
582
+ catch { /* 环境没有 WeakRef → 实况显示"没有会话" */ }
583
+ }
238
584
  // 空 turn 不注入(官方 dsh-agent-instructions 同款守卫):step 1 且一条消息都没有时,
239
585
  // 这一步本来就该原地结束(宿主随后把 turn 判为 completed)。此时注入会把空 turn
240
586
  // 变成一次真实的模型请求 —— 凭空烧一次调用。
241
587
  if (Number(payload.step) === 1 && messagesOf(decision).length === 0)
242
588
  return decision;
243
589
  const domains = deps.domains();
244
- const sections = selectInjections(domains, deps.settings(), await deps.factsFor(agent));
245
- const visible = newestDomainTexts(agent);
590
+ const settings = deps.settings();
591
+ // 被用户关掉的官方载体域:连官方那条消息一起**不放行**(见 officialKindsToSuppress)。
592
+ // 边界说明:这不是改官方包、也不是改宿主机制 —— pre-step 的 decision 本来就是每个插件
593
+ // 都能改的那条缝(官方自己就在这里追加消息),我们只把它剔出这一步的批次。代价是官方
594
+ // 插件每一步都会重新渲染并尝试注入(它的历史读的是会话事件,读不到被拦下的那条),
595
+ // 模型侧不受影响。位置在本监听器 `next()` 之后:本插件在这条瀑布里位于官方之前
596
+ // (自己的消息总落在批次末尾,实测),所以官方这一步追加的消息在这里看得见。
597
+ const suppressedKinds = officialKindsToSuppress(settings);
598
+ let messages = messagesOf(decision);
599
+ if (suppressedKinds.size > 0) {
600
+ const kept = messages.filter((message) => {
601
+ const kind = kindOfMessage(message);
602
+ return kind === undefined || !suppressedKinds.has(kind);
603
+ });
604
+ if (kept.length !== messages.length)
605
+ messages = kept;
606
+ }
607
+ const facts = await deps.factsFor(agent);
608
+ const sections = selectInjections(domains, settings, facts, agent);
609
+ // 记录每个域这一步"为什么发 / 为什么不发",供「注入实况」解释状态(口径见 explainInjections)。
610
+ lastReasons = explainInjections(domains, settings, facts, agent);
611
+ const visible = newestDomainTexts(agent, PLUGIN_KINDS);
246
612
  const additions = [];
613
+ const appended = [];
614
+ // 本步真正投出去的**域正文**(不含下面的「已清空」通知):采纳统计的 `injected` 只算它,
615
+ // 「已清空」不是一次"给了模型内容",算进去会让分母虚高。
616
+ const injectedKeys = new Set();
247
617
  const published = new Set();
618
+ // 域声明按 key 索引:来源文件(`files`)只在真要发消息时取,所以要能从这里回查声明。
619
+ const domainOf = new Map(domains.map((domain) => [domain.key, domain]));
248
620
  for (const section of sections) {
249
621
  published.add(section.key);
250
622
  const text = renderDomainText(section);
251
- if (visible.get(section.key) === text)
623
+ const current = visible.get(section.key);
624
+ if (current !== undefined && current.text === text)
252
625
  continue;
253
- additions.push(domainMessage(section, text));
626
+ // `current !== undefined` = 该域在可见表面上已有一条更早的己方消息 → 这次是**替换**,
627
+ // 界面上的动作标签据此从「已载入」变成「已更新」(见 domainMessage 的 baseline/changes)。
628
+ let files;
629
+ try {
630
+ files = domainOf.get(section.key)?.files?.();
631
+ }
632
+ catch {
633
+ files = undefined;
634
+ }
635
+ additions.push(domainMessage(section, text, files, current !== undefined));
636
+ appended.push(section.key);
637
+ injectedKeys.add(section.key);
254
638
  }
255
639
  // 曾经注入过、这一轮没有内容的域 → 一条「已清空」;从没注入过的域什么都不用说。
256
640
  const labelOf = new Map(domains.map((domain) => [domain.key, domain.label]));
@@ -261,13 +645,36 @@ export function createContextInjector(deps) {
261
645
  if (previous === undefined)
262
646
  continue;
263
647
  const label = labelOf.get(key) ?? key;
264
- if (previous === clearedDomainText(key, label))
648
+ if (previous.form === 'notice')
265
649
  continue;
266
650
  additions.push(clearedMessage(key, label));
651
+ appended.push(key);
267
652
  }
268
- if (additions.length === 0)
269
- return decision;
270
- return { ...decision, messages: [...messagesOf(decision), ...additions] };
653
+ // 采纳判定要用的现场:**含官方载体**,并并入本步刚投出去的域正文(它们就在这一步的
654
+ // 请求里,模型当场看得到)。判断"发不发"只能用本插件自己的 kind —— 官方正文绝不能
655
+ // 影响去重(见 PLUGIN_KINDS 的注释);判断"模型眼前有没有这份内容"则必须连官方那份
656
+ // 一起算,标准类预设下技能与提示词正是官方在送。两遍扫描,两种口径各自正确。
657
+ // 位置在"提前返回"之前:这一步没东西可发时,现场依然是当前状态,同样要记。
658
+ try {
659
+ if (typeof agent === 'object') {
660
+ const liveKeys = new Set(newestDomainTexts(agent, LIVE_KINDS).keys());
661
+ for (const key of injectedKeys)
662
+ liveKeys.add(key);
663
+ liveDomainsByAgent.set(agent, liveKeys);
664
+ }
665
+ }
666
+ catch { /* 采纳遥测拿不到现场就退化成"只记 used",绝不影响注入 */ }
667
+ if (additions.length === 0) {
668
+ // 没有新增时,只有"拦下了官方消息"才需要返回改动后的 decision;否则原样返回。
669
+ return messages === messagesOf(decision) ? decision : { ...decision, messages: [...messages] };
670
+ }
671
+ delivered.count += additions.length;
672
+ delivered.lastAt = Date.now();
673
+ for (const key of appended)
674
+ delivered.byDomain[key] = (delivered.byDomain[key] || 0) + 1;
675
+ for (const key of injectedKeys)
676
+ adoption[key].injected += 1;
677
+ return { ...decision, messages: [...messages, ...additions] };
271
678
  }
272
679
  catch (error) {
273
680
  // 注入是尽力而为:任何异常都不能把这一步弄失败。
@@ -281,16 +688,38 @@ export function createContextInjector(deps) {
281
688
  stop();
282
689
  }
283
690
  catch { /* ignore */ } },
691
+ live,
692
+ noteToolUse,
284
693
  };
285
694
  }
286
695
  function messagesOf(decision) {
287
696
  const messages = decision && decision.messages;
288
697
  return Array.isArray(messages) ? messages : [];
289
698
  }
290
- function domainMessage(section, text) {
699
+ /**
700
+ * 一条域消息。`source` 上带的字段是**给界面的结构化数据**(模型侧只看 content,不受影响):
701
+ * - snapshot 形态带 `sections`(界面分节显示,框架句由界面用固定 caption 顶替);
702
+ * - instructions 形态带 `changes`(+ 首帧的 `baseline`)—— 官方 `dsh-agent-instructions`
703
+ * 的同款契约(`dsh-client-ui-chat` 读 `path` + `action` ∈ set/replace/remove + 可选 digest),
704
+ * 界面据此把这条渲染成「文件清单 + 正文」而不是一坨原文。
705
+ * `action` 由**是否替换**决定:本域此前没有可见消息 = 首帧 → `set` + `baseline: true`
706
+ * (界面显示「已载入」);有 → `replace`(「已更新」)。`baseline` 只在真时写:界面判
707
+ * `=== true`,写 false 是噪声。缺 `files`(域没提供、或提供时抛错)→ 两个字段都不写,
708
+ * 界面退回原文渲染,消息内容不变。
709
+ */
710
+ function domainMessage(section, text, files, replacing) {
291
711
  const source = { kind: INJECT_KIND_OF[section.key], form: section.form };
292
712
  if (section.form === 'snapshot')
293
713
  source.sections = [{ name: section.name, text: section.text }];
714
+ if (section.form === 'instructions' && files !== undefined && files.length > 0) {
715
+ if (replacing !== true)
716
+ source.baseline = true;
717
+ source.changes = files.map((file) => ({
718
+ action: replacing === true ? 'replace' : 'set',
719
+ path: file.path,
720
+ ...(file.digest !== undefined && file.digest !== '' ? { digest: file.digest } : {}),
721
+ }));
722
+ }
294
723
  return createUserMessage({
295
724
  content: [{ type: 'text', text }],
296
725
  source,