dsh-plugin-tool-management 0.20.3 → 0.21.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -1
  3. package/README_EN.md +4 -1
  4. package/docs/update.md +1 -0
  5. package/lib/client.js +117 -11
  6. package/lib/compat/capability-specs.js +60 -1
  7. package/lib/compat/host-contracts.js +72 -0
  8. package/lib/compat/host-probe.js +59 -0
  9. package/lib/compat/host-roots.js +1 -1
  10. package/lib/compat/probe.js +84 -13
  11. package/lib/context-inject-frame.js +134 -26
  12. package/lib/context-inject.js +12 -9
  13. package/lib/http-api.js +3 -2
  14. package/lib/http-fence.js +26 -1
  15. package/lib/index.js +5 -2
  16. package/lib/mcp/manager.js +6 -1
  17. package/lib/mcp/state-section.js +75 -34
  18. package/lib/memories/constants.js +39 -7
  19. package/lib/memories/projection.js +18 -18
  20. package/lib/memories/service.js +8 -7
  21. package/lib/memories/snapshot.js +27 -17
  22. package/lib/ops/compat.js +64 -2
  23. package/lib/ops/memory.js +2 -2
  24. package/lib/ops/prompts.js +30 -2
  25. package/lib/request-gate.js +5 -4
  26. package/lib/skills/catalog.js +2 -2
  27. package/lib/subagents/catalog.js +4 -4
  28. package/lib/subagents/child-preset.js +120 -0
  29. package/lib/subagents/persona-parse.js +9 -9
  30. package/lib/subagents/run-provider.js +218 -62
  31. package/lib/subagents/tool-filter.js +12 -12
  32. package/lib/subagents/tools.js +29 -22
  33. package/lib/tools/deps.js +4 -4
  34. package/lib/tools/mcp.js +34 -34
  35. package/lib/tools/memory.js +34 -34
  36. package/lib/tools/prompt.js +5 -5
  37. package/lib/tools/remove.js +17 -17
  38. package/lib/tools/scene.js +66 -66
  39. package/lib/tools/skills.js +21 -21
  40. package/lib/tools/subagent.js +13 -13
  41. package/lib/tools/table.js +2 -2
  42. package/package.json +25 -24
package/lib/http-api.js CHANGED
@@ -46,9 +46,10 @@ export function installHttpApi(deps) {
46
46
  req.on('error', reject);
47
47
  });
48
48
  // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
49
- // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
49
+ // 没有全局中间件,既定约定是每个注册方自己调 connection.admit(req):
50
50
  // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
51
- // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
51
+ // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权,
52
+ // 并交回这次请求所代表的 Peer(本路由是 exact 路由,只服务 owner Peer)。
52
53
  // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
53
54
  // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
54
55
  const connection = (() => {
package/lib/http-fence.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // 为什么需要它:宿主的 BrowserAuth 栅栏**只包在宿主自己注册的路由上**
4
4
  // (dsh-client-connection 的 register()/upgrade、dsh-api-gateway、
5
5
  // dsh-host-open-in-app),dsh-host-webserver 本身没有全局中间件。宿主生态的
6
- // 既定约定是每个注册方自己调 `connection.requestRejection(req)`,它做两件事:
6
+ // 既定约定是每个注册方自己调 `connection.admit(req)`(0.2.1 起的完整版,
7
+ // `requestRejection(req)` 是它只取状态码的那层包装),它做两件事:
7
8
  //
8
9
  // 1. Host/Origin 栅栏 —— Host 必须是回环地址、deployment 派生的 LAN IP 字面量
9
10
  // 或声明的 trustedHosts。Host 是 DNS rebinding 唯一伪造不了的头:攻击者
@@ -11,6 +12,9 @@
11
12
  // (origin.host === host)会同时成立,于是被误判为同源。
12
13
  // 2. browser-session cookie 鉴权(HttpOnly + SameSite=Strict,Path=/)。
13
14
  //
15
+ // `admit()` 还交回这次请求所代表的 **Peer**,而本插件的 exact 路由按官方契约只服务
16
+ // owner Peer(非 owner 一律 403),所以 principal 也要验 —— 只取状态码会漏掉这一层。
17
+ //
14
18
  // 只查公开常量 `x-dsh-plugin` 与自比对 Origin 等于没鉴权:本机任意进程都能调写 op,
15
19
  // 而写 op 里包含 `mcpm-add`(command/args 无白名单,宿主 stdio 传输会 spawn 它
16
20
  // → 任意命令执行)与 `history-delete`(永久删除会话)。
@@ -33,6 +37,27 @@ const headerValue = (req, name) => {
33
37
  */
34
38
  export function fenceRejection(req, connection) {
35
39
  try {
40
+ // 优先 `admit()`(0.2.1 起存在):它做 Host/Origin 栅栏 + 浏览器 session 鉴权,
41
+ // 并把"这次请求代表谁"作为 Peer 交出来。`requestRejection()` 只给状态码 —— 状态码
42
+ // 足以回答"拦不拦",却不足以回答"这个调用方被授权到什么程度",而本插件的路由是
43
+ // **exact 路由**(`kind: 'exact'`,官方契约:只有 owner Peer 才该拿到响应)。
44
+ // 没有 principal 信息时只能当作非 owner 处理 —— 这正是下面 403 那一支的理由。
45
+ const admission = connection?.admit?.(req);
46
+ if (admission !== undefined) {
47
+ if ('rejection' in admission) {
48
+ const status = admission.rejection;
49
+ return {
50
+ status,
51
+ error: status === 401
52
+ ? '未通过宿主的浏览器鉴权:请在 DSH Web GUI 内操作,或为本插件配置访问令牌(config.token / DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)'
53
+ : 'Host/Origin 栅栏拒绝:该请求不是发往本机地址的同源请求',
54
+ };
55
+ }
56
+ if (admission.peer?.principal?.kind !== 'owner') {
57
+ return { status: 403, error: '拒绝:调用方不是本机所有者身份' };
58
+ }
59
+ return null;
60
+ }
36
61
  const rejection = connection?.requestRejection?.(req);
37
62
  if (typeof rejection === 'number') {
38
63
  return {
package/lib/index.js CHANGED
@@ -727,7 +727,7 @@ export default {
727
727
  // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
728
728
  //
729
729
  // 深度判据(`applicableTo`)**逐域写在域声明上**,三个域各一条,语义都**不是**"能不能
730
- // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider 只在
730
+ // 委派"。委派可行性由官方决定:`dsh-subagent` 默认 `maxDepth: 1`(0.2.1 实测核对),provider 只在
731
731
  // 传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫"还有没有
732
732
  // 委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里 `catalogDepth` 的注释。
733
733
  // · 场景与记忆:**恒为"只在顶层"**。这两段说的是**父会话的处境**(此刻在哪个场景、
@@ -749,7 +749,7 @@ export default {
749
749
  { key: 'scene', name: 'tool-management:scene', label: '场景', form: 'snapshot', text: () => memoriesService.sceneCatalogText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
750
750
  // 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
751
751
  { key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
752
- { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
752
+ { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: (agent) => mcp.stateCatalog.text(agent) },
753
753
  { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: (agent) => skillCatalog.text(agent) },
754
754
  {
755
755
  key: 'subagents',
@@ -1395,6 +1395,9 @@ export default {
1395
1395
  pluginHiddenTools: () => [...toolTableHidden()],
1396
1396
  // 有官方等价物时逐 agent 让位(见上面 CARRIER_DUPLICATES 的注释)。
1397
1397
  carrierHiddenTools: carrierHiddenToolsFor,
1398
+ // 状态段尾句那句「用 `mcp_manager_list` 查」的前提:与技能 / 子智能体两个目录同一份判据
1399
+ // (2026-10-18 审查 B)。
1400
+ listToolVisible: (agent) => toolVisibleFor('mcp_manager_list', agent),
1398
1401
  });
1399
1402
  // ---------- ops ----------
1400
1403
  async function pluginVersion() {
@@ -113,7 +113,12 @@ export function createMcpManager(deps) {
113
113
  const { readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools, pluginInventory, settings, readPatch, writePatch, memoriesService, wait, } = deps;
114
114
  // MCP 状态段(注入通道的数据源)由本域自己建:它要的 rows 就是本域的 mcpmRowsWithNotes(false),
115
115
  // 让 index.ts 建再传进来会形成 index ↔ manager 的循环引用。
116
- const mcpStateCatalog = createMcpStateCatalog({ rows: () => mcpmRowsWithNotes(false) });
116
+ // `listToolVisible` 是唯一从外面进来的:它要看**这个 agent** 手里有没有 `mcp_manager_list`
117
+ // (兼容页开关 + 人设 toolsDeny),判据在 index.ts 的 `toolVisibleFor`,不在这里复制一份。
118
+ const mcpStateCatalog = createMcpStateCatalog({
119
+ rows: () => mcpmRowsWithNotes(false),
120
+ ...(deps.listToolVisible === undefined ? {} : { listToolVisible: deps.listToolVisible }),
121
+ });
117
122
  // ---------- duplicate loader-id guard ----------
118
123
  // Two rows with the same loader id make the plugin composition fail to
119
124
  // boot (upstream hit exactly this after renaming an entry), so duplicates
@@ -9,26 +9,30 @@
9
9
  // 这类信息在任何工具 schema 里都拿不到:工具只暴露 `mcp__<server>__<tool>` 的名字与
10
10
  // 参数,永远不会说「A 挂了改用 B」。所以「列出 server」只是载体。
11
11
  //
12
- // 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"的判据):
13
- // - 分两档,判据全部可验证:
12
+ // 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"的判据;2026-10-18 加第三档):
13
+ // - 分三档,判据全部可验证:
14
14
  // ① **可用**:配置上已启用 **且** 当前真实注册了未被停用的工具
15
15
  // (`liveEnabledToolCount > 0`;场景档案收窄与手动逐工具开关都会写停用表,这里如实扣减)。
16
- // ② **曾经连上、现在不可用**:已启用、当前一个工具都没注册,但「已知工具」缓存非空。
16
+ // ② **连着但工具全被停用**:当前注册了工具、但停用表把它们全扣光(`liveToolCount > 0`
17
+ // 而 `liveEnabledToolCount === 0`)。2026-10-18 起列出来:它是"模型手上一条 MCP 工具
18
+ // 都没有,可 server 确实在跑"的唯一线索,而此前不列的理由("留着只会谎报数量")
19
+ // 随工具数一起作废了。
20
+ // ③ **曾经连上、现在不可用**:已启用、当前一个工具都没注册,但「已知工具」缓存非空。
17
21
  // 这是**状态变化**,要报 —— 模型据此给"检查网络/命令/凭据"的建议,而不是把
18
22
  // "配了但连不上"读成"本机没配"(后者会让它建议用户去装一个)。同时,用户写在那台
19
23
  // server 上的备注(placeholder 就是「A 不可用时改用 B 兜底」)恰恰只在此时才有用,
20
24
  // 不列就永远送不到。
21
- // 不列的三类:**没开**(用户明确要求)、**工具全被停用**(模型调不到,留着只会谎报数量)、
22
- // **从未连上过**(缓存也空 —— 一直没成功过,不该反复打扰;这条抄的是 Claude Code 的通知
23
- // 策略:一直没连上的不提示,昨天还好今天挂的才提示)。
24
- // - 只列 **server 名 + 状态(仅②档)+ 备注**;**具体工具名绝不注入** ——
25
+ // 不列的两类:**没开**(用户明确要求)、**从未连上过**(缓存也空 —— 一直没成功过,不该反复
26
+ // 打扰;这条抄的是 Claude Code 的通知策略:一直没连上的不提示,昨天还好今天挂的才提示)。
27
+ // - 只列 **server 名 + 状态(②③档)+ 备注**;**具体工具名绝不注入** ——
25
28
  // 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
26
29
  // 段里再列一遍是重复;模型按前缀就能对上号。
27
30
  // - **工具数不列**(2026-09-23):模型不能凭"这台有 N 个工具"做任何事(能调哪些在它自己的
28
31
  // schema 里),而 17 B/台 × 每轮的开销是白付的。
29
- // - 无备注 → 只有名字(①档)或名字 + 状态(②档)。
30
- // - 不写「已启用」字样:能进段的都是可用的,标状态是废话;②档则必须标,否则就是谎报。
31
- // - 两档都空 → 返回 `''`(renderPrompt 删空段,零 token 成本)。
32
+ // - 无备注 → 只有名字(①档)或名字 + 状态(②③档)。
33
+ // - 不写「已启用」字样:能进段的都是已启用的,标状态是废话;②③档则必须标,否则就是谎报。
34
+ // - **三档都空才返回 `''`**(无 server 可讲时零 token 成本)。注意 2026-10-18 的
35
+ // 「无备注就不发」已被同日的用户裁定推翻,理由见 `renderMcpStateSection` 的注释。
32
36
  //
33
37
  // 一条**测不出来**的边界(写在这里免得后来者以为它是 bug):宿主没有暴露"MCP 已连接"这个信号
34
38
  // (插件清单只有 entryId / moduleName / enabled / fiberPhase),所以本模块只能读"工具有没有注册"。
@@ -49,11 +53,11 @@ export const DEFAULT_MCP_NOTE_MAX_LENGTH = 300;
49
53
  * 备注的固定前缀(模型侧口径,用户裁定 2026-09-16:字段与段统一叫 user-hint / 用户提示)。
50
54
  *
51
55
  * 原为「备注:」,取「降低被读成指令的概率」——备注是自由文本,注入后**由插件背书**地到达模型。
52
- * 改叫「用户提示:」是有意为之:它本来就该被读成**用户的决策提示**(「A 挂了改用 B」),
56
+ * 改叫 `user hint:` 是有意为之:它本来就该被读成**用户的决策提示**(「A 挂了改用 B」),
53
57
  * 而不是系统策略。威胁模型不变(内容由用户自己写、仅本机可写,属**自伤**而非被攻击),
54
58
  * 但前缀如实标明来源,模型据此权衡即可。
55
59
  */
56
- export const MCP_NOTE_PREFIX = '用户提示:';
60
+ export const MCP_NOTE_PREFIX = 'user hint: ';
57
61
  /**
58
62
  * 备注压成单行(换行 → 空格)并截断。
59
63
  *
@@ -70,12 +74,17 @@ export function normalizeMcpNote(value, maxLength) {
70
74
  /**
71
75
  * 渲染 MCP 状态段(纯函数,便于单独推理)。
72
76
  *
73
- * 分两档:**可用**(当前真注册了可用工具)与**曾经连上、现在不可用**(当前没注册,
74
- * 但「已知工具」缓存证明它曾经成功过)。判据与不列的三类见文件头。
77
+ * **整段的取舍**(2026-10-18 用户裁定「无备注不发这样不好」,推翻了同日的 A 方案):
78
+ * 只要有一台该说的 server 就发,不因为"没有备注"而整段省略。理由:
79
+ * - 这一域是**唯一**说明"这台机器上配了哪些 server、各自什么状态"的地方,而模型手里的工具表
80
+ * 只说得出"我现在能调什么"。少了它,"配过但没连上 / 连着但工具被关掉"与"根本没配过"
81
+ * 在模型看来完全等价 —— 那正是本模块存在的理由(见文件头)。
82
+ * - 代价已经不是问题:`how` 行按需发、权威声明只在替换时发之后,只有名字的那种段在上下文里
83
+ * 只剩标题 + 一两行,且内容不变就不重发。
75
84
  *
76
- * 返回 `''` 表示不注入 —— 没有可用 server 的用户零 token 成本。
85
+ * @param listToolVisible `mcp_manager_list` 在这个 agent 手里吗(尾句那个出口的前提)
77
86
  */
78
- export function renderMcpStateSection(rows, opts = {}) {
87
+ export function renderMcpStateSection(rows, opts = {}, listToolVisible = true) {
79
88
  const maxEntries = opts.maxEntries ?? DEFAULT_MCP_MAX_ENTRIES;
80
89
  const noteMaxLength = opts.noteMaxLength ?? DEFAULT_MCP_NOTE_MAX_LENGTH;
81
90
  // 当前真实可用的工具数(停用表扣减后)。**优先读 live 那个数** —— 旧调用方只给
@@ -91,12 +100,20 @@ export function renderMcpStateSection(rows, opts = {}) {
91
100
  const liveOf = (r) => (Number.isFinite(Number(r.liveToolCount)) ? Number(r.liveToolCount) : liveEnabledOf(r));
92
101
  // 曾经连上过的证据。缺省 0 = 从未连上过(旧调用方给不出这个信号,按"不列"处理 —— 少说不错)。
93
102
  const knownOf = (r) => Number(r.knownToolCount) || 0;
103
+ // 三档,判据全部可验证:
104
+ // ① `usable` —— 已启用,当前真注册了未被停用的工具(模型现在就能调)。
105
+ // ② `muted` —— 已启用、也真的连着(注册过工具),但**工具全被停用**:场景档案收窄或
106
+ // 手动逐工具开关的结果。2026-10-18 起列出来并标注(此前不列):它是
107
+ // "模型手上一条工具都没有,但 server 确实在跑"的**唯一**线索,而旧理由
108
+ // ("留着只会谎报数量")随着工具数一起消失了 —— 现在只给名字与状态。
109
+ // ③ `offline` —— 已启用、当前一个工具都没注册,但「已知工具」缓存非空(曾经连上过)。
94
110
  const usable = rows.filter((r) => !r.disabled && liveEnabledOf(r) > 0);
111
+ const muted = rows.filter((r) => !r.disabled && liveOf(r) > 0 && liveEnabledOf(r) === 0);
95
112
  const offline = rows.filter((r) => !r.disabled && liveOf(r) === 0 && knownOf(r) > 0);
96
- if (!usable.length && !offline.length)
113
+ if (!usable.length && !muted.length && !offline.length)
97
114
  return '';
98
115
  // 按展示名排序:即使补丁文件里的行序变化,段文本也保持逐字节稳定(前缀缓存契约)。
99
- // 两档各自排序,可用的一律排在前面(它才是模型当下能用的东西)。
116
+ // 三档各自排序,可用的一律排在前面(它才是模型当下能用的东西)。
100
117
  const byName = (a, b) => {
101
118
  const x = String(a.serverName ?? '');
102
119
  const y = String(b.serverName ?? '');
@@ -104,48 +121,65 @@ export function renderMcpStateSection(rows, opts = {}) {
104
121
  };
105
122
  const cap = Math.max(0, maxEntries);
106
123
  const shownUsable = [...usable].sort(byName).slice(0, cap);
124
+ const shownMuted = [...muted].sort(byName).slice(0, cap);
107
125
  const shownOffline = [...offline].sort(byName).slice(0, cap);
108
- const lineOf = (r, count, isOffline) => {
109
- const name = String(r.serverName ?? r.id);
110
- // ②档必须把状态写出来:不写就等于谎报"可用"。
126
+ const lineOf = (row, status) => {
127
+ const name = String(row.serverName ?? row.id);
128
+ // 非"可用"的两档必须把状态写出来:不写就等于谎报"可用"。
111
129
  //
112
130
  // 2026-09-23:两档的**工具数都去掉了**(用户看到实际注入后要求精简)。数字对模型没有
113
131
  // 动作价值 —— 它不能凭"这台有 8 个工具"列举出工具名(真正能调的都在它自己的工具
114
132
  // schema 里,按 `mcp__<server>__` 前缀就能对上号),而每轮都要付 17 B/台。
115
133
  // 顺带消掉一处隐患:②档原来写「上次连上时 N 个工具」,那个数字是**历史事实**,措辞上
116
134
  // 必须和现状区分开;数字没了,这个歧义也就不存在了。
117
- const head = isOffline ? '- **' + name + '**(当前未连上)' : '- **' + name + '**';
118
- const note = normalizeMcpNote(r.notes, noteMaxLength);
135
+ const head = status === 'offline'
136
+ ? '- **' + name + '** (not connected now)'
137
+ : status === 'muted'
138
+ ? '- **' + name + '** (no tools enabled here)'
139
+ : '- **' + name + '**';
140
+ const note = normalizeMcpNote(row.notes, noteMaxLength);
119
141
  return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
120
142
  };
121
143
  const lines = [
122
- ...shownUsable.map((r) => lineOf(r, liveEnabledOf(r), false)),
123
- ...shownOffline.map((r) => lineOf(r, knownOf(r), true)),
144
+ ...shownUsable.map((row) => lineOf(row, 'ok')),
145
+ ...shownMuted.map((row) => lineOf(row, 'muted')),
146
+ ...shownOffline.map((row) => lineOf(row, 'offline')),
124
147
  ];
125
- // 只有清单:标题(`# 当前 MCP 服务器`)由注入通道的框架承担
148
+ // 只有清单:标题(`# Current MCP servers`)由注入通道的框架承担
126
149
  // (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
127
150
  // 框架已经写了标题,正文再写一遍就是每步多付一行 token。
128
151
  const out = [...lines];
129
- const hidden = (usable.length + offline.length) - (shownUsable.length + shownOffline.length);
130
- if (hidden > 0)
131
- out.push('', `(另有 ${hidden} 台未列出。)`);
152
+ const hidden = (usable.length + muted.length + offline.length)
153
+ - (shownUsable.length + shownMuted.length + shownOffline.length);
154
+ // 超上限时给出口(2026-10-18 审查 B):技能与子智能体两个目录早就这么写了,这是唯一漏掉
155
+ // 的一处。工具被关掉(兼容页 / 人设 toolsDeny)时不点名 —— 点名一个模型手里没有的工具,
156
+ // 只会让它去猜名字;数量照报,那是实话。
157
+ if (hidden > 0) {
158
+ out.push('', listToolVisible
159
+ ? `(${hidden} more not listed — see them with \`mcp_manager_list\`.)`
160
+ : `(${hidden} more not listed.)`);
161
+ }
132
162
  return out.join('\n');
133
163
  }
134
164
  /** 创建 SWR 的 MCP 状态目录。计算失败时保留上一次的值,不把段清空。 */
135
165
  export function createMcpStateCatalog(deps, opts = {}) {
136
166
  const ttlMs = opts.ttlMs ?? 3000;
137
167
  const now = opts.now ?? (() => Date.now());
138
- let value = '';
168
+ // 缓存的是**行数据**而不是渲染结果(与技能 / 子智能体两个目录同一手法):清单只依赖磁盘,
169
+ // 而尾句要不要点名查询工具取决于**这个 agent 手里有没有它**。渲染是纯字符串操作,代价可忽略。
170
+ let data = null;
139
171
  let loadedAt = Number.NEGATIVE_INFINITY;
140
172
  let inflight = null;
173
+ let rendered = null;
141
174
  const recompute = async () => {
142
175
  try {
143
176
  const result = await deps.rows();
144
177
  if (result && result.ok !== false) {
145
- value = renderMcpStateSection(result.rows ?? [], opts);
178
+ data = result.rows ?? [];
179
+ rendered = null;
146
180
  }
147
181
  }
148
- catch { /* 保留上一次的值;首次失败则维持 '' */ }
182
+ catch { /* 保留上一次的值;首次失败则维持 null */ }
149
183
  loadedAt = now();
150
184
  };
151
185
  const revalidate = () => {
@@ -155,10 +189,17 @@ export function createMcpStateCatalog(deps, opts = {}) {
155
189
  return inflight;
156
190
  };
157
191
  return {
158
- text: () => {
192
+ text: (agent) => {
159
193
  if (now() - loadedAt > ttlMs)
160
194
  void revalidate();
161
- return value;
195
+ if (data === null)
196
+ return '';
197
+ const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
198
+ if (rendered && rendered.visible === visible)
199
+ return rendered.text;
200
+ const out = renderMcpStateSection(data, opts, visible);
201
+ rendered = { visible, text: out };
202
+ return out;
162
203
  },
163
204
  refresh: () => {
164
205
  loadedAt = Number.NEGATIVE_INFINITY;
@@ -34,10 +34,22 @@ export const GLOBAL_SCENE = 'global';
34
34
  export const GLOBAL_SCENE_LABEL = '全局';
35
35
  export const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
36
36
  // ── 段渲染 ─────────────────────────────────────────────────────────────────
37
- export const TRUNCATION_MARKER = '<!-- truncated -->';
37
+ /**
38
+ * 截断标记。**给模型看的一句英文**,不再是 `<!-- truncated -->`(2026-10-18 审查)。
39
+ *
40
+ * 旧值是个 HTML 注释:对人眼不可见(要的就是"别显示"),但模型读的是**原始 markdown** ——
41
+ * 它看到的是一串 `<!-- truncated -->`,既不知道这是什么、也不知道该拿它怎么办。注入正文里
42
+ * 没有"注释"这个概念,凡是进上下文的东西都该是给模型的一句话。
43
+ *
44
+ * 现在的写法说清两件事:有内容没进来(`Truncated`)、进来的是少于全部的(`not shown`)。
45
+ * 具体漏了哪些由段尾的 `## Not injected` 清单回答,标记只在清单本身也放不下时才独自出现。
46
+ */
47
+ export const TRUNCATION_MARKER = '(Truncated: some entries are not shown.)';
38
48
  // 段尾清单:让模型知道自己漏了什么。去掉伞标题后这里也不再挂「场景记忆」前缀 ——
39
- // 它紧跟在场景块之后,`参考信息` 与段首引导语同一说法。
40
- export const DROPPED_HEADING = '## 未注入的参考信息(超出预算)';
49
+ // 它紧跟在场景块之后,与段首引导语同一说法。2026-10-18 起两者都是英文(见 SCENE_MEMORY_NOTE)。
50
+ // 每行只有 `场景/名称`:原先还带 `(N B)`,那个字节数对人有用、对模型零动作价值
51
+ // (它不能凭"这条 800 B"决定读不读)—— 2026-10-18 审查按"注入只写模型能用的信息"删掉。
52
+ export const DROPPED_HEADING = '## Not injected (over the size limit)';
41
53
  /** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
42
54
  export const INLINE_BODY_MAX = 120;
43
55
  /** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
@@ -63,7 +75,7 @@ export const ATTACHMENT_LIST_MAX = 10;
63
75
  *
64
76
  * 用词:不用「常驻」「注入」这类内部行话(模型没有先验)。
65
77
  * ⚠️ 「用『信息』而不是『记忆』」那条**已被 2026-09-23 第四次裁定推翻**(见下):用户
66
- * 给定的定稿文案里就是「记忆」,而板块标题本来就是「当前记忆」—— 两者同名反而更好,
78
+ * 给定的定稿文案里就是「记忆」,而板块标题本来就是 `Current memories` —— 两者同名反而更好,
67
79
  * 当时那条理由(「记忆」在系统提示词里指代不明)在板块标题定名之后也不成立了。
68
80
  *
69
81
  * 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
@@ -110,7 +122,7 @@ export const ATTACHMENT_LIST_MAX = 10;
110
122
  * 时以实际情况为准、④ 与用户当场说的冲突时以用户为准、⑤ 无关不必提 + 完整性声明(防探测,
111
123
  * 见上,那半句是整句里唯一不能删的)。77 → 48 字符(219 → 144 字节),而它**每个场景块各来
112
124
  * 一次**,所以省的是 N 倍。删掉的只有修饰与重复:「以下是用户为本机写的记录」→「本机记录」
113
- * (板块标题 `# 当前记忆` 已经把"这是本机的记忆"说过了),两句"冲突时以…为准"
125
+ * (板块标题 `# Current memories` 已经把"这是本机的记忆"说过了),两句"冲突时以…为准"
114
126
  * 合成一句。
115
127
  *
116
128
  * 2026-09-23 第四次裁定(用户直接给定稿文案,逐字):
@@ -118,7 +130,7 @@ export const ATTACHMENT_LIST_MAX = 10;
118
130
  * 相对第三版有三处变化,都不是措辞偏好:
119
131
  * ① 「本机记录」→「以下是**当前场景对应的**记忆」:说清这是**哪个范围**的记忆。记忆本来
120
132
  * 就按场景组织,而这一段列出的条目确实全来自"当前启用的场景 + 两个保留桶" —— 比
121
- * "本机记录"准确,也解释了为什么下面的条目按 `## 场景:X` 分组。
133
+ * "本机记录"准确,也解释了为什么下面的条目按 `## Scene: X` 分组。
122
134
  * ② 「用户当场说的」→「用户**目前的对话**」:同一个意思,后者更贴注入发生的场合。
123
135
  * ③ **删掉「可能已过期」,以及完整性声明「以下就是全部信息」**。
124
136
  * - 「可能已过期」:用户判断它与紧接的「冲突时以实际情况为准」重复 —— 后半句已经把
@@ -139,12 +151,32 @@ export const ATTACHMENT_LIST_MAX = 10;
139
151
  * ② **`global` 的分组标题从 `## 场景:全局` 改成 `## 常驻信息`**(见 `sceneHeading`):
140
152
  * 全局是恒常生效的保留桶,不是"一个场景",写成场景会让模型以为它是当前场景的另一种
141
153
  * 取值。
154
+ *
155
+ * 2026-10-18(用户裁定):**模型侧模板一律英文**,这句引导语的五个功能一个不动 ——
156
+ * ① 这是当前场景对应的记忆 ② 冲突时以实际情况与用户当场说的为准 ③ 无关勿提(不必提及,
157
+ * 不是不采用)④ 完整性声明(防探测)。**用户写的内容(记忆正文、场景名与说明)原样注入。**
158
+ *
159
+ * 同日晚些时候二次收紧(用户:「让 agent 更能注意到信息,信息高效,也不要冗余」):
160
+ * `when` → `where`,`do not bring them up unless relevant` → `bring them up only when relevant`。
161
+ * 后者不是同义改写:否定式读起来像"对内容的态度许可"(正是第三次裁定要拆掉的那个毛病),
162
+ * 正向式说的是"可以提,只是别主动提"。四个功能一个没少,字数少 11 个。
142
163
  */
143
164
  /**
144
165
  * 记忆段(`memory-manager-catalog`)的授权语:条目是**记录**(权威等级低于"用户当场说的")。
145
166
  * 放在**板块层**(第一个场景块之前),整段只出现一次 —— 见 `renderBody` 尾部。
146
167
  */
147
- export const SCENE_MEMORY_NOTE = '**以下是当前场景对应的记忆;冲突时以实际情况和用户目前的对话为准;无关勿提;以下就是全部信息:**';
168
+ export const SCENE_MEMORY_NOTE = '**Memories for the current scene; where they conflict with what you observe or with what the user says in this conversation, trust that instead; bring them up only when relevant; this is the complete set:**';
169
+ /**
170
+ * 同一条授权语,**去掉完整性声明**的版本 —— 只在真发生截断时用(2026-10-18 审查恢复分叉)。
171
+ *
172
+ * 第五次裁定当时判定"真截断时的矛盾可以接受",理由是默认 128 KiB 正常碰不到。但
173
+ * `rulesMaxBytes` 是公开旋钮(`index.ts` 的 config),预算配小时这个矛盾就是**每一条注入都在
174
+ * 说谎**:段首说"这就是全部",段尾的 `## Not injected` 紧接着列出没进来的条目。模型按段首
175
+ * 那句判断"没有别的记忆了",正好是这条声明本来要防的那个误判。
176
+ *
177
+ * 预算仍按全量版预留(`sceneNoteBytes`),而截断态这句更短,所以恢复分叉不会挤掉任何条目。
178
+ */
179
+ export const SCENE_MEMORY_NOTE_PARTIAL = '**Memories for the current scene; where they conflict with what you observe or with what the user says in this conversation, trust that instead; bring them up only when relevant:**';
148
180
  // ── bundle 与索引 ──────────────────────────────────────────────────────────
149
181
  // bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
150
182
  export const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
@@ -129,38 +129,38 @@ export function sceneLabel(scene, index) {
129
129
  /**
130
130
  * 记忆段里的场景**分组**标题。
131
131
  *
132
- * `global` 是恒常生效的保留桶,不是"一个场景" —— 叫它 `## 场景:全局` 会让模型以为"全局"
132
+ * `global` 是恒常生效的保留桶,不是"一个场景" —— 叫它 `## Scene: 全局` 会让模型以为"全局"
133
133
  * 是当前场景的另一种取值(用户 2026-09-23:「全局不需要写成场景」)。所以它单独成一种标题:
134
- * `## 常驻信息`。其余场景仍是 `## 场景:X`。
134
+ * `## Always-on memories`。其余场景仍是 `## Scene: X`。
135
135
  *
136
- * `_shared`(历史保留名)保持 `## 场景:_shared` 不动 —— 它只在旧数据里出现,且没有
136
+ * `_shared`(历史保留名)保持 `## Scene: _shared` 不动 —— 它只在旧数据里出现,且没有
137
137
  * "全局"这种"它不是场景"的语义歧义(它确实曾是一个共享场景)。
138
138
  */
139
- export const sceneHeading = (scene) => scene === GLOBAL_SCENE ? '## 常驻信息' : `## 场景:${sceneLabel(scene)}`;
139
+ export const sceneHeading = (scene) => scene === GLOBAL_SCENE ? '## Always-on memories' : `## Scene: ${sceneLabel(scene)}`;
140
140
  /**
141
- * 场景段(`scene-manager-catalog`)里的一行:`**「<场景名>」—— 用户描述:<场景说明>**`。
141
+ * 场景段(`scene-manager-catalog`)里的一行:`**"<场景名>" — user description: <场景说明>**`。
142
142
  *
143
- * 2026-09-23 用户裁定,替换了原来的两行式(`## 场景:X` + `**场景说明:X**`)。三条理由都是
143
+ * 2026-09-23 用户裁定,替换了原来的两行式(`## Scene: X` + `**场景说明:X**`)。三条理由都是
144
144
  * 用户看到**实际注入**之后提的:
145
145
  * - **场景名只出现一次**。上一版里它出现三次(框架线索"现在处在哪个场景"、引导语里的
146
- * 「代码」、标题里的 `## 场景:代码`),而说明行只是把同一件事换个标签再说一遍。
146
+ * 「代码」、标题里的 `## Scene: 代码`),而说明行只是把同一件事换个标签再说一遍。
147
147
  * - **场景说明是「标签」,不是「约定」**。它答的是"这个场景是干什么的",用户的实例就是
148
148
  * 「写代码」「前端相关」。上一版把它写成"一律照办、覆盖你的默认做法",等于要求模型
149
149
  * "照办一个标签" —— 那正是它读不懂这一段的根源。授权语已删,说明只作注解跟在名字后面。
150
150
  * - **没填说明就不带后缀**。上一版会补 `defaultSceneDescription` 的默认句,而
151
151
  * "用户配置的上下文,当前启用"贴在名字后面,读起来像一句真的说明。
152
152
  *
153
- * `用户描述:` 这个前缀是同日追加的(用户:「防止模型误解这是场景名或者其它」):破折号后面
153
+ * `user description:` 这半句是同日追加的(用户:「防止模型误解这是场景名或者其它」):破折号后面
154
154
  * 那截的形态本来有歧义 —— `**「代码」—— 写代码**` 读起来像"名字 —— 别名",而它其实是
155
- * **用户写的一句说明**。加四个字把来源点明,模型就不会把它当成场景的另一个名字或某种标识符。
156
- * 前缀只在真有描述时出现(没填描述时整截不出现,不存在"用户描述:"后面空着)。
155
+ * **用户写的一句说明**。加这半句把来源点明,模型就不会把它当成场景的另一个名字或某种标识符。
156
+ * 前缀只在真有描述时出现(没填描述时整截不出现,不存在"user description:"后面空着)。
157
157
  *
158
158
  * 为什么不再用 `##` 标题:这一段只讲"当前启用的是哪个 + 它是什么",一行说完就够。
159
159
  * 场景**分组**标题仍在记忆段里用(`sceneHeading`),两段各自承担自己的职责。
160
160
  */
161
161
  export const sceneLine = (scene, index) => {
162
162
  const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
163
- return `**「${sceneLabel(scene)}」${described === '' ? '' : `—— 用户描述:${described}`}**\n\n`;
163
+ return `**"${sceneLabel(scene)}"${described === '' ? '' : ` — user description: ${described}`}**\n\n`;
164
164
  };
165
165
  /** 场景渲染顺序:全局 `global` 最先(它的记忆对任何对话都成立,先讲总则),
166
166
  * 其次 `_shared`(历史保留名),其余按(索引 scenes.order, 场景名)。 */
@@ -246,7 +246,7 @@ export function attachmentLine(file) {
246
246
  return '';
247
247
  const shown = names.slice(0, ATTACHMENT_LIST_MAX);
248
248
  const more = names.length - shown.length;
249
- return `附件:${file.bundleDir}(${shown.join('、')}${more > 0 ? ` 等 ${names.length} 个` : ''})`;
249
+ return `Attachments: ${file.bundleDir} (${shown.join(', ')}${more > 0 ? `, ${names.length} total` : ''})`;
250
250
  }
251
251
  // ── 单条记忆的渲染 ─────────────────────────────────────────────────────────
252
252
  /**
@@ -254,7 +254,7 @@ export function attachmentLine(file) {
254
254
  *
255
255
  * 为什么不是"缩进 2 格就完事"(这是上一版的实现,被实测推翻):markdown 里缩进 ≤3 格的 `#`
256
256
  * **仍然算标题**,所以正文里一句手写的 `# 代码/需求` 会渲染成 H1,**盖过容器自己的
257
- * `## 场景:X`** —— 用户看到的就是这个:内容看起来"逃出了条目",与场景标题平级甚至更高。
257
+ * `## Scene: X`** —— 用户看到的就是这个:内容看起来"逃出了条目",与场景标题平级甚至更高。
258
258
  * 缩到 4 格能压住标题(块级标记都要求 ≤3 格缩进),但正文里的列表/引用只是被"压平",
259
259
  * 视觉上仍与条目正文同级,达不到"明显从属"。
260
260
  *
@@ -279,8 +279,8 @@ export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.descript
279
279
  /**
280
280
  * 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
281
281
  *
282
- * 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
283
- * 多行或过长的正文 → `- **名称**(描述)` + 空行 + **围栏代码块里的正文**(挂在条目下;理由见 `bodyBlock`)
282
+ * 单行且不长的正文 → `- **名称** (描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
283
+ * 多行或过长的正文 → `- **名称** (描述)` + 空行 + **围栏代码块里的正文**(挂在条目下;理由见 `bodyBlock`)
284
284
  *
285
285
  * 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
286
286
  * 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
@@ -293,14 +293,14 @@ export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.descript
293
293
  * 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
294
294
  * 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
295
295
  * Markdown**(可能自带标题、代码块、嵌套列表),放进围栏块挂到条目下 —— 上一版让它以普通
296
- * 段落参与外围排版,结果正文里的 `#` 会盖过 `## 场景:X`(见 `bodyBlock` 的说明)。
296
+ * 段落参与外围排版,结果正文里的 `#` 会盖过 `## Scene: X`(见 `bodyBlock` 的说明)。
297
297
  *
298
298
  * 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
299
299
  */
300
300
  export function memoryBlock(file) {
301
301
  const desc = explicitDescriptionOf(file);
302
- // 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
303
- const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
302
+ // 加粗的只有名称:`- **名称** (描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
303
+ const title = `**${file.name}**` + (desc === '' ? '' : ` (${desc})`);
304
304
  const text = String(file.body ?? '').trim();
305
305
  const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
306
306
  const head = text === ''
@@ -661,9 +661,10 @@ export function createMemoriesService(ctx, deps) {
661
661
  * sha1 hex):算的是**文件内容**(AGENTS.md 取截断前的原文),是"文件这一版"的身份,
662
662
  * 不是"这次注入了什么";界面只拿它当悬停提示,不参与任何判定。
663
663
  *
664
- * `text` 是**模型侧**正文,开头一行 `来源:<展示路径>` —— 照抄官方 agent-instructions 的
665
- * 写法(`Instructions from: <path>`,`render.js` 的 `sectionText`)。模型据此知道这些规则
666
- * 写在哪个文件里:用户说"把这条记下来"时它知道该改哪份文件,而不是只能凭印象回答。
664
+ * `text` 是**模型侧**正文,开头一行 `Instructions from: <展示路径>` —— 与官方 agent-instructions
665
+ * 逐字同形(`render.js` 的 `sectionText`;2026-10-18 由 `Source:` 改过来:官方注入本来就用
666
+ * 这个措辞,模型对它有现成的先验)。模型据此知道这些规则写在哪个文件里:用户说"把这条记下来"
667
+ * 时它知道该改哪份文件,而不是只能凭印象回答。
667
668
  */
668
669
  const resolvePrompt = () => {
669
670
  // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
@@ -678,11 +679,11 @@ export function createMemoriesService(ctx, deps) {
678
679
  const body = Buffer.byteLength(scene.text, 'utf8') <= AGENTS_MD_MAX_BYTES
679
680
  ? scene.text
680
681
  : Buffer.from(scene.text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
681
- `\n\n(…预设正文超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
682
+ `\n\n(Truncated: the preset body is over ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB.)`;
682
683
  return {
683
684
  path,
684
685
  digest: createHash('sha1').update(scene.text).digest('hex'),
685
- text: `来源:${path}\n\n${body}`,
686
+ text: `Instructions from: ${path}\n\n${body}`,
686
687
  };
687
688
  }
688
689
  const raw = readGlobalAgentsMdSync();
@@ -692,8 +693,8 @@ export function createMemoriesService(ctx, deps) {
692
693
  const body = Buffer.byteLength(raw, 'utf8') <= AGENTS_MD_MAX_BYTES
693
694
  ? raw
694
695
  : Buffer.from(raw, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
695
- `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
696
- return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `来源:${path}\n\n${body}` };
696
+ `\n\n(Truncated: the file is over ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB.)`;
697
+ return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `Instructions from: ${path}\n\n${body}` };
697
698
  };
698
699
  /**
699
700
  * 模型侧的提示词正文(`''` = 没有可注入的内容)。