dsh-plugin-tool-management 0.9.1 → 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.
@@ -559,11 +559,6 @@ export function assessHost(ctx) {
559
559
  generatedAt: Date.now(),
560
560
  };
561
561
  }
562
- /** True when two paths denote the same file; falls back to string equality. */
563
- function samePath(a, b) {
564
- const normalize = (value) => value.replace(/\\/g, '/').toLowerCase();
565
- return normalize(realPathOf(a) ?? a) === normalize(realPathOf(b) ?? b);
566
- }
567
562
  /**
568
563
  * The `@deepseek-ai` directory of the DSH installation this plugin is attached
569
564
  * to, derived from where a shared package physically lives.
@@ -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` 的镜像,供「注入实况」解释状态)。
131
241
  *
132
- * 引导语只有**一句话**(用户裁定 2026-09-16:五个域各发各的以后,原来那四行说明在每条消息里
133
- * 重复一遍太冗余):说清"这是什么 + 取代谁"就够 —— 域的名字、怎么查细节(`*_manager_list`
134
- * 就在模型自己的工具表里)都不必在这里再说一遍。
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
+ * 声明)+ 空行 + 域正文。**五个域一律带框架**,没有例外。
342
+ *
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)。 */
@@ -170,6 +394,27 @@ const OFFICIAL_KIND_OF = {
170
394
  'skill-catalog': 'skills',
171
395
  'agent-instructions': 'prompt',
172
396
  };
397
+ /**
398
+ * 用户**明确关掉**的域 → 该域对应的官方消息 kind(这些 kind 的消息这一步不放行)。
399
+ *
400
+ * 交互事实(用户 2026-09-17 指出):技能与提示词两域在标准类预设下由官方送(本插件让位),
401
+ * 于是"取消勾选"只停掉了本插件自己,官方那条照样进上下文 —— 用户看到的是"关了没用"。
402
+ * 本函数给出需要**连带拦下**的官方 kind:只有"官方自己会送同份内容"的两个域有这一项;
403
+ * MCP / 记忆 / 人设目录官方不送,没有可拦的对象(它们的开关本来就完全生效)。
404
+ */
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
+ }
173
418
  /** 实况展示用的 kind 表:本插件的五条 + 官方两条。 */
174
419
  const LIVE_KINDS = new Map([
175
420
  ...PLUGIN_KINDS,
@@ -228,10 +473,12 @@ const bytesOf = (text) => {
228
473
  }
229
474
  };
230
475
  /**
231
- * 注册 `agent/pre-step` 注入监听;返回清理函数。
476
+ * 注册 `agent/pre-step` 注入监听;返回清理函数与采纳遥测入口。
232
477
  *
233
478
  * 宿主平面注册即可覆盖所有 agent(含子智能体、含任何预设)——dsh-scope 的
234
479
  * `scopeTarget` 过滤对没有 scope 标记的 ctx 直接放行,官方 time-context 就是这么挂的。
480
+ * "覆盖到子智能体"这件事本身是**特性**(子会话同样需要记忆与提示词),只有人设目录
481
+ * 那一域对它不成立,由域声明的 `applicableTo` 单独挡掉 —— 通道不替域做决定。
235
482
  */
236
483
  export function createContextInjector(deps) {
237
484
  const log = (message) => {
@@ -244,7 +491,15 @@ export function createContextInjector(deps) {
244
491
  // 包括"空 turn 提前返回"和"这一步没有内容可发"的分支,页面才能如实说"没投过"。
245
492
  let lastAgent = null;
246
493
  const delivered = { count: 0, lastAt: null, byDomain: {} };
247
- // 最近一步里"每个域为什么发/不发"(官方载体 / 开关关 / 空 / 本插件负责)。
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
+ // 最近一步里"每个域为什么发/不发"(官方载体 / 开关关 / 空 / 子会话不适用 / 本插件负责)。
248
503
  // 只在没有可见注入时用来解释状态 —— 标准类预设下技能与提示词由官方在送,
249
504
  // 插件的实况若只说"未投递",读起来像出了问题。
250
505
  let lastReasons = {};
@@ -270,19 +525,48 @@ export function createContextInjector(deps) {
270
525
  // 本插件发过又清空的,报「已清空」。
271
526
  ? (entry.official ? 'official' : entry.form === 'notice' ? 'cleared' : 'in-context')
272
527
  // 没在上下文里:用最近一步的原因解释;`sent`(该发)却没看到 = 压缩后还没补发。
273
- : reason === 'off' ? 'off' : reason === 'official' ? 'official' : reason === 'empty' ? 'empty' : 'absent';
528
+ : reason === 'off' ? 'off' : reason === 'official' ? 'official' : reason === 'empty' ? 'empty' : reason === 'child' ? 'child' : 'absent';
274
529
  const text = (state === 'in-context' || state === 'official') && entry !== undefined ? entry.text : '';
275
- return { key, label: labelOf.get(key) ?? key, kind: INJECT_KIND_OF[key], state, bytes: bytesOf(text), 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
+ };
276
539
  });
277
540
  return {
278
541
  hasAgent: agent !== undefined,
279
542
  delivered: { count: delivered.count, lastAt: delivered.lastAt, byDomain: { ...delivered.byDomain } },
543
+ observed: { toolCalls: observed.toolCalls, lastAt: observed.lastAt },
280
544
  domains: rows,
281
545
  };
282
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
+ };
283
567
  const ctx = deps.ctx;
284
568
  if (!ctx || typeof ctx.on !== 'function')
285
- return { dispose: () => { }, live };
569
+ return { dispose: () => { }, live, noteToolUse };
286
570
  const stop = ctx.on('agent/pre-step', async (payload, next) => {
287
571
  const decision = await next();
288
572
  try {
@@ -304,44 +588,53 @@ export function createContextInjector(deps) {
304
588
  return decision;
305
589
  const domains = deps.domains();
306
590
  const settings = deps.settings();
307
- const facts = await deps.factsFor(agent);
308
- const sections = selectInjections(domains, settings, facts);
309
- // 记录每个域这一步"为什么发 / 为什么不发",供「注入实况」解释状态:
310
- // off = 开关关了;official = 官方载体在送(技能 / 提示词,标准类预设下的常态);
311
- // empty = 本插件负责但没内容;sent = 本插件负责且有内容(上下文里看不到时说明还没补发)。
312
- const reasons = {};
313
- for (const domain of domains) {
314
- if (settings.domains[domain.key] !== true) {
315
- reasons[domain.key] = 'off';
316
- continue;
317
- }
318
- const carrier = CARRIER_FACT_OF[domain.key];
319
- if (carrier !== undefined && facts?.[carrier] !== false) {
320
- reasons[domain.key] = 'official';
321
- continue;
322
- }
323
- let text = '';
324
- try {
325
- text = String(domain.text() ?? '');
326
- }
327
- catch {
328
- text = '';
329
- }
330
- reasons[domain.key] = text.trim() === '' ? 'empty' : 'sent';
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;
331
606
  }
332
- lastReasons = reasons;
607
+ const facts = await deps.factsFor(agent);
608
+ const sections = selectInjections(domains, settings, facts, agent);
609
+ // 记录每个域这一步"为什么发 / 为什么不发",供「注入实况」解释状态(口径见 explainInjections)。
610
+ lastReasons = explainInjections(domains, settings, facts, agent);
333
611
  const visible = newestDomainTexts(agent, PLUGIN_KINDS);
334
612
  const additions = [];
335
613
  const appended = [];
614
+ // 本步真正投出去的**域正文**(不含下面的「已清空」通知):采纳统计的 `injected` 只算它,
615
+ // 「已清空」不是一次"给了模型内容",算进去会让分母虚高。
616
+ const injectedKeys = new Set();
336
617
  const published = new Set();
618
+ // 域声明按 key 索引:来源文件(`files`)只在真要发消息时取,所以要能从这里回查声明。
619
+ const domainOf = new Map(domains.map((domain) => [domain.key, domain]));
337
620
  for (const section of sections) {
338
621
  published.add(section.key);
339
622
  const text = renderDomainText(section);
340
623
  const current = visible.get(section.key);
341
624
  if (current !== undefined && current.text === text)
342
625
  continue;
343
- 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));
344
636
  appended.push(section.key);
637
+ injectedKeys.add(section.key);
345
638
  }
346
639
  // 曾经注入过、这一轮没有内容的域 → 一条「已清空」;从没注入过的域什么都不用说。
347
640
  const labelOf = new Map(domains.map((domain) => [domain.key, domain.label]));
@@ -357,13 +650,31 @@ export function createContextInjector(deps) {
357
650
  additions.push(clearedMessage(key, label));
358
651
  appended.push(key);
359
652
  }
360
- if (additions.length === 0)
361
- return decision;
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
+ }
362
671
  delivered.count += additions.length;
363
672
  delivered.lastAt = Date.now();
364
673
  for (const key of appended)
365
674
  delivered.byDomain[key] = (delivered.byDomain[key] || 0) + 1;
366
- return { ...decision, messages: [...messagesOf(decision), ...additions] };
675
+ for (const key of injectedKeys)
676
+ adoption[key].injected += 1;
677
+ return { ...decision, messages: [...messages, ...additions] };
367
678
  }
368
679
  catch (error) {
369
680
  // 注入是尽力而为:任何异常都不能把这一步弄失败。
@@ -378,16 +689,37 @@ export function createContextInjector(deps) {
378
689
  }
379
690
  catch { /* ignore */ } },
380
691
  live,
692
+ noteToolUse,
381
693
  };
382
694
  }
383
695
  function messagesOf(decision) {
384
696
  const messages = decision && decision.messages;
385
697
  return Array.isArray(messages) ? messages : [];
386
698
  }
387
- 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) {
388
711
  const source = { kind: INJECT_KIND_OF[section.key], form: section.form };
389
712
  if (section.form === 'snapshot')
390
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
+ }
391
723
  return createUserMessage({
392
724
  content: [{ type: 'text', text }],
393
725
  source,
@@ -9,6 +9,13 @@ export { CapabilityRefusalError };
9
9
  // presence, shapes, read-only behaviour), never from comparing source text:
10
10
  // text equality only ever proved "same release", and it turned every harmless
11
11
  // upstream refactor into a total feature outage.
12
+ //
13
+ // 纪律边界(对工作区「官方包与宿主机制只读」一条的解释,2026-09-18 复核确认):
14
+ // 这里的包装是**运行时实例**上的临时、可恢复、能力门控的适配(仅当宿主自己缺
15
+ // delete/whenIdle 时才装;按原 descriptor 恢复;serial 队列防复活),不修改任何
16
+ // 官方包文件、不持久化改动、不改变宿主机制的对外行为 —— 与「不 patch 官方代码」
17
+ // 禁令针对的对象(包文件与宿主机制的永久改写)不同层。若用户裁定该解释不成立,
18
+ // 撤掉 acquire/releaseCacheGuard 即可整体退回「无删除屏障」的降级形态。
12
19
  const cacheGuards = new WeakMap();
13
20
  const raw = (value) => value?.[symbols.original] ?? value;
14
21
  const sameLifecycle = (a, b) => a && b && a.createdAt === b.createdAt && (a.cwd ?? null) === (b.cwd ?? null);
@@ -1,8 +1,7 @@
1
1
  //#region lib/types/tombstone.js
2
2
  /**
3
3
  * 通用墓碑簿记:登记一个已删除 id,并按 FIFO 在上限处淘汰最旧项。
4
- * 工作区注册表(lib/workspace.js)与投影缓存(lib/projcache.js)共用
5
- * 同一份语义,避免两处拷贝的淘汰策略分叉。
4
+ * 工作区注册表(lib/history/workspace.js)在用;淘汰策略集中在这里,避免各处拷贝分叉。
6
5
  */
7
6
  /**
8
7
  * 登记已删除 id 并执行上限淘汰。
package/lib/hub.js CHANGED
@@ -116,7 +116,9 @@ export function migrateHubLayoutSync(home = resolveDshHome()) {
116
116
  }
117
117
  };
118
118
  // ① 更早的 hub 目录名(插件叫 dsh-skill-mcp-manager 的时期):**先**逐项并入 hub,
119
- // 让下面 ② 的改名也覆盖从旧目录搬进来的那些(同名保留 hub 里已有的那份)。
119
+ // 让下面 ② 的改名也覆盖从旧目录搬进来的那些(同名保留 hub 里已有的那份 ——
120
+ // move 对已存在目标跳过且旧份不删:合并是「只进不覆盖」,滞留旧目录的条目
121
+ // 不丢失但也不可见,清掉旧目录即可整体放弃)。
120
122
  for (const legacyHub of ['dsh-plugin-tool-management', 'skill-mcp-manager']) {
121
123
  const from = join(home, legacyHub);
122
124
  try {