dsh-tool-folder 0.2.5 → 0.3.2

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.
package/lib/index.js CHANGED
@@ -1,983 +1,1261 @@
1
- /**
2
- * dsh-tool-folder — fold the DSH tool surface down to what each request needs.
3
- *
4
- * Six layers (L0-L5), all implemented here:
5
- * L1 schema compression — conservative description trimming for dyn tools
6
- * L2 dynamic loading — per-agent core + BM25 top-K + CN-alias servers
7
- * L3 selection metrics — selectionCoverage / retrievalMetrics recorded
8
- * L4 execution side — folded tools stay executable + heat promotion +
9
- * `tools_search` tool for on-demand discovery
10
- * L5 cache discipline — stable ordering (core by config, hot/dyn sorted
11
- * by name) so the prompt prefix stays byte-stable
12
- * (L0 description rewrite is a separate offline batch — see README)
13
- *
14
- * Mechanism (verified against DSH 0.1.1-rc.1 sources, 2026-08-24):
15
- * assembly = { sections, contexts, tools, variables } and the waterfall
16
- * `system-prompt/assemble` lets a plugin REPLACE the object authoritatively;
17
- * buildRequest uses assembly.tools as the ONLY source of the API tools field.
18
- * Execution is NOT gated on the current turn's tools, so folding never
19
- * removes capability.
20
- * `tools/pre-execute` — listener receives (exec, next); return
21
- * { kind: "deny", reason } to refuse, or call next() to allow.
22
- * exec = { callId, name, arguments, agent, signal } (no .deny() method).
23
- * `tools/post-execute` — listener receives (exec, result, next); fires once
24
- * per executed tool. exec.name/exec.arguments feed the L3/L4 feedback
25
- * loop. (`agent/pre-step` carries { messages, position, signal } only —
26
- * no toolCalls — so it is useless for feedback.)
27
- *
28
- * Safety: any error falls back to the untouched assembly. Performance: BM25
29
- * in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
30
- */
31
- import fs from "node:fs";
32
- import path from "node:path";
33
- import z from "@deepseek-ai/schemastery";
34
- import { buildIndex, search } from "./bm25.js";
35
- import { compressTool, normalizeDescription } from "./schema.js";
36
- import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, missingMetaTools } from "./metrics.js";
37
- import { tierForBudget } from "./budget.js";
38
- import { readState, writeState } from "./storage.js";
39
- import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
40
- import { ChainGuard, verdict, isExecTool } from "./chainguard.js";
41
- import { matchCategory } from "./category.js";
42
- import { toonifyValue } from "./toonify.js";
43
- import { configureSemantic, buildSemanticIndex, searchSemantic, rrfFuse } from "./semantic.js";
44
- import { defineTool } from "@deepseek-ai/dsh-tools";
45
-
46
- export const name = "tool-folder";
47
- export const inject = ["systemPrompt", "agents", "tools"];
48
-
49
- /**
50
- * Schemastery configuration schema — renders the plugin's settings form in the
51
- * DSH settings UI (per cordis `runtime.Config["~standard"]`). Mirrors DEFAULTS
52
- * below; the UI exposes every toggle/knob this plugin has.
53
- * Same pattern as @deepseek-ai/dsh-tool-todo (z.object + .default()).
54
- */
55
- const COMPRESS_LEVELS = ["off", "light", "standard", "aggressive"];
56
- export const Config = z.object({
57
- enabled: z.boolean().default(true).description("总开关:启用工具折叠"),
58
- core: z.array(z.string()).default([]).description("每轮恒显示的工具(平台核心,永不折叠)"),
59
- perAgent: z.dict(z.object({
60
- core: z.array(z.string()).default([]).description("该 agent 的常驻工具覆盖(未列出 agent 回退到全局 core)"),
61
- })).default({}).description("按 agent 覆盖常驻工具:{\"standard\": {\"core\": [...]}}"),
62
- deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
63
- topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
64
- hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
65
- hotWindowDays: z.number().min(0).max(365).default(3)
66
- .description("hot 热度的滑动窗口(天):窗口内达标才常驻,超窗自动衰减;0=永不过期"),
67
- catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
68
- compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
69
- compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
70
- schemaToolEnabled: z.boolean().default(true).description("注册 tools_schema 元工具:按名称展开任意工具的完整参数 schema"),
71
- normalizeDescriptions: z.boolean().default(false).description("描述标准化:注入消毒+首句提取+300 字符(只作用于动态段)"),
72
- category: z.dict(z.array(z.string())).default({}).description("意图路由:{\"记忆/回忆\": [\"mcp__viking\"]},优先级 category > aliases > BM25"),
73
- include: z.array(z.string()).default([]).description("白名单:[\"mcp__openhands*\"],空=全部(deny 仍优先,meta 工具豁免)"),
74
- toonifyResults: z.boolean().default(false).description("长 JSON 结果紧凑化(删空字段/截长串,默认关)"),
75
- toolSearchEnabled: z.boolean().default(true).description("注册 tools_search 元工具:检索被折叠的工具目录"),
76
- firewallEnabled: z.boolean().default(true).description("ChainGuard 防火墙:高危命令硬拦截+外泄链检测"),
77
- maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
78
- feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
79
- aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
80
- semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
81
- ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
82
- embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
83
- embedTimeoutMs: z.number().min(100).default(8000).description("embedding 请求超时(毫秒;Ollama 慢时可调大)"),
84
- disclosureBudget: z.number().min(0).max(200000).default(0)
85
- .description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
86
- maxWarmTools: z.number().min(0).max(50).default(0)
87
- .description("warm LRU 上限:tools_search/schema 发现或折叠后被调用的工具进入 warm 集合,下一轮注入(完整 schema);达上限逐出最久未用;0=关闭(默认关,无配置升级逐字节等价,显式开启才启用)"),
88
- fallbackCatalogSize: z.number().min(0).max(200).default(30)
89
- .description("P0: tools_search 零匹配时回退的轻目录条数(name+80字描述,名字典序);0=关闭回退(返回空=旧行为)"),
90
- queryExpandEnabled: z.boolean().default(true)
91
- .description("P0: 中文意图→英文同义词扩展(只加词不改原词,扩大 BM25/语义召回;精确名匹配不受影响)"),
92
- closureSize: z.number().min(0).max(20).default(0)
93
- .description("P1: tools_schema 展开时附带同 server 兄弟工具的轻目录条数(name+120字描述,名字典序);0=关闭(默认关=无配置升级输出逐字段等价,显式开启才启用,与 maxWarmTools 先例一致)"),
94
- });
95
-
96
- const DEFAULTS = {
97
- enabled: true,
98
- perAgent: {},
99
- core: [],
100
- deny: [], // never inject + refuse execution (exact name or "prefix*")
101
- topK: 6,
102
- hotThreshold: 3,
103
- hotWindowDays: 3, // v0.2.1: sliding-window heat decay (0 = never expire)
104
- catalogEnabled: true, // safety net: folded tools stay discoverable by name
105
- compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
106
- compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
107
- schemaToolEnabled: true, // P0-1: register the tools_schema meta tool
108
- normalizeDescriptions: false, // P1-2: sanitize+first-sentence+300 dyn descriptions
109
- category: {}, // P1-1: { "记忆/回忆": ["mcp__viking"], ... } intent routing
110
- include: [], // P2-1: whitelist ("prefix*" or exact name); empty = all (minus deny)
111
- toonifyResults: false, // P2-2: compact long JSON results (default off)
112
- toolSearchEnabled: true,
113
- firewallEnabled: true, // ChainGuard: high-risk hard block + exfil chain warn
114
- maxFoldMs: 50,
115
- feedbackFile: "",
116
- aliases: {
117
- "mcp__viking": ["记忆", "记住", "回忆", "remember", "memory"],
118
- "mcp__openhands": ["写代码", "编程", "编码", "开发", "coding", "code", "openhands", "委派", "delegate"],
119
- "mcp__context7": ["文档", "api 文档", "库文档", "documentation", "docs", "context7"],
120
- "mcp__deeptutor": ["学习", "课程", "教学", "tutor", "study", "learn"],
121
- "mcp__reasonix": ["推理", "复杂任务", "深度思考", "reasoning", "reasonix"],
122
- "mcp__open-design": ["设计", "design"],
123
- "mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
124
- },
125
- semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
126
- ollamaBase: "http://127.0.0.1:11434",
127
- embedModel: "bge-m3",
128
- embedTimeoutMs: 8000, // P3-2: embedding 请求超时(毫秒)
129
- disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
130
- maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
131
- fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
132
- queryExpandEnabled: true, // P0: 中文→英文 query 扩展
133
- closureSize: 0, // P1: tools_schema 同 server 兄弟闭包(默认关=逐字段等价,显式开启)
134
- };
135
-
136
- function feedbackPath(cfg) {
137
- if (cfg.feedbackFile) return cfg.feedbackFile;
138
- // Host-side plugins run in the real Node runtime (not the dynamic sandbox),
139
- // so process.env is available. Guarded anyway: fall back to a relative path.
140
- try {
141
- const home = process.env.DSH_HOME || "";
142
- if (home) return `${home}/logs/tool-folder/feedback.json`;
143
- return `${process.env.APPDATA || "."}/dsh/logs/tool-folder/feedback.json`;
144
- } catch {
145
- return "tool-folder/feedback.json";
146
- }
147
- }
148
-
149
- function loadFeedback(file, log) {
150
- try {
151
- if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
152
- } catch (e) {
153
- log?.warn?.("feedback load failed (%s) — starting empty", e?.message);
154
- }
155
- return { calls: {}, sessions: [] };
156
- }
157
-
158
- function persistFeedback(file, fb, log) {
159
- try {
160
- fs.mkdirSync(path.dirname(file), { recursive: true });
161
- fs.writeFileSync(file, JSON.stringify(fb, null, 2), "utf8");
162
- } catch (e) {
163
- log?.warn?.("feedback persist failed (%s) — non-fatal", e?.message);
164
- }
165
- }
166
-
167
- /** Serialize an exec.arguments payload (object) into text for firewall verdicts. */
168
- function execArgumentsText(args) {
169
- if (args == null) return "";
170
- if (typeof args === "string") return args;
171
- try {
172
- return JSON.stringify(args);
173
- } catch {
174
- return String(args);
175
- }
176
- }
177
-
178
- function extractQuery(context) {
179
- const signal = context && context.signal;
180
- if (!signal) return "";
181
- try {
182
- const s = signal.userMessage?.content ?? signal.query ?? signal.prompt;
183
- if (typeof s === "string") return s;
184
- if (Array.isArray(signal.messages)) {
185
- for (let i = signal.messages.length - 1; i >= 0; i--) {
186
- const m = signal.messages[i];
187
- if (m && m.role === "user" && typeof m.content === "string") return m.content;
188
- }
189
- }
190
- } catch {
191
- /* unknown shape */
192
- }
193
- return "";
194
- }
195
-
196
- function serverOf(name) {
197
- // "mcp__github__get_repo" → "mcp__github"; "openhands_create_conversation" → "openhands";
198
- // native tools with underscores ("read_file", "search_for_pattern") → "native".
199
- const m = name.match(/^(mcp__[^_]+(?:_[^_]+)?)/);
200
- if (m) return m[1];
201
- if (/^openhands_/.test(name)) return "openhands";
202
- return "native";
203
- }
204
-
205
- function summarizeCaps(tools) {
206
- // Pull the first few distinct capability keywords from descriptions.
207
- const seen = new Set();
208
- const caps = [];
209
- for (const t of tools) {
210
- const d = (t.description || "").replace(/\s+/g, " ").trim();
211
- const head = d.split(/[—,;:]/)[0].slice(0, 60);
212
- if (head && !seen.has(head)) {
213
- seen.add(head);
214
- caps.push(head);
215
- }
216
- if (caps.length >= 3) break;
217
- }
218
- return caps.join(" | ") || "capabilities unknown";
219
- }
220
-
221
- function catalogText(tools) {
222
- // Affordance fix: group folded tools by server and emit a capability
223
- // summary per server (not a callable-looking name list). The model must
224
- // expand arguments via tools_schema before calling a folded tool.
225
- const byServer = new Map();
226
- for (const t of tools) {
227
- const s = serverOf(t.name);
228
- if (!byServer.has(s)) byServer.set(s, []);
229
- byServer.get(s).push(t);
230
- }
231
- const lines = [...byServer.entries()]
232
- .sort((a, b) => b[1].length - a[1].length)
233
- .map(([server, ts]) => `- [${server}]: ${ts.length} tools — ${summarizeCaps(ts)}`);
234
- return [
235
- "Additional tools exist but are not loaded for this request. " +
236
- "They are grouped by server below. To call one: first expand its " +
237
- "arguments with tools_schema (exact tool name), then call it by name.",
238
- ...lines,
239
- ].join("\n");
240
- }
241
-
242
- export function apply(ctx, config) {
243
- const cfg = { ...DEFAULTS, ...(config || {}) };
244
-
245
- // T01: normalize the v0.1.5 config surface right after the DEFAULTS merge.
246
- // - compressLevel accepts only the four valid tiers; anything else → "off".
247
- // - compressEnabled is the deprecated compat switch: "off" + true → "standard".
248
- // - include patterns are trimmed/non-empty; empty list = include-all.
249
- // - category must be a plain object; malformed values are ignored.
250
- const VALID_LEVELS = ["off", "light", "standard", "aggressive"];
251
- const compressLevel = VALID_LEVELS.includes(cfg.compressLevel) ? cfg.compressLevel : "off";
252
- const effectiveLevel = compressLevel !== "off" ? compressLevel : (cfg.compressEnabled ? "standard" : "off");
253
- const includePatterns = (cfg.include || []).map((n) => String(n).trim()).filter(Boolean);
254
- const includeAll = includePatterns.length === 0;
255
- const categoryCfg = (cfg.category && typeof cfg.category === "object") ? cfg.category : {};
256
-
257
- const logger = ctx.logger("tool-folder");
258
- if (!cfg.enabled) {
259
- logger.info("disabled by config");
260
- return;
261
- }
262
-
263
- // Semantic leg (P0-1): point the shared module at our config; the index is
264
- // built lazily on first tools_search call (doc embeddings cached on disk,
265
- // so the ~10s cold start happens once, then it's a cache hit).
266
- configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel, embedTimeoutMs: cfg.embedTimeoutMs });
267
- let semIndexPromise = null;
268
- const getSemanticIndex = () => {
269
- if (!semIndexPromise) {
270
- semIndexPromise = (async () => {
271
- const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
272
- return buildSemanticIndex(docs);
273
- })();
274
- }
275
- return semIndexPromise;
276
- };
277
-
278
- const fbPath = feedbackPath(cfg);
279
- const feedback = loadFeedback(fbPath, logger);
280
- let index = null;
281
- let indexStamp = null;
282
- let allTools = [];
283
- let lastVisible = { agentId: "", names: [], ts: 0 };
284
- const disposers = [];
285
- const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
286
-
287
- /* ------------------------------------------------------------------ */
288
- /* v0.2.2: warm LRU + 配置三级持久化 */
289
- /* ------------------------------------------------------------------ */
290
- // warm:Map<name, {last}>,LRU 顺序由 last 单调递增序号决定。被
291
- // tools_search 搜到 / tools_schema 展开 / 折叠后仍被调用的工具进入集合,
292
- // 下一轮注入完整 schema。maxWarmTools=0 时整个 warm 失效(仍不 touch)。
293
- const warm = new Map();
294
- const warmEnabled = cfg.maxWarmTools > 0;
295
- const touchWarmNames = (names) => {
296
- if (!warmEnabled || !Array.isArray(names) || names.length === 0) return;
297
- touchWarm(warm, names, Date.now(), cfg.maxWarmTools);
298
- persistWarmState();
299
- };
300
-
301
- // 三级持久化:① feedbackPath 同目录(首选)→ ② ~/.dsh/state/ → ③ 进程内。
302
- // ⚠️ 绝不写 cordis.patch.yml(宿主配置,插件只读宿主、只落自有 state 文件)。
303
- const primaryStateFile = () => path.join(path.dirname(fbPath), "tool-folder-state.json");
304
- const fallbackStateFile = () => {
305
- const home = process.env.DSH_HOME || process.env.USERPROFILE || process.env.HOME || "";
306
- return home ? `${home}/.dsh/state/tool-folder-state.json` : "tool-folder-state.json";
307
- };
308
- let activeStateFile = primaryStateFile();
309
- const restoreWarm = () => {
310
- if (!warmEnabled) return; // 关闭态绝不载入磁盘残留(Spec 2: maxWarmTools=0=彻底关闭)
311
- let cloud = readState(activeStateFile, null);
312
- if (!cloud) {
313
- activeStateFile = fallbackStateFile();
314
- cloud = readState(activeStateFile, null);
315
- }
316
- if (cloud && Array.isArray(cloud.warm)) {
317
- for (const [name, meta] of cloud.warm) {
318
- if (typeof name === "string" && meta && typeof meta.last === "number") {
319
- warm.set(name, { last: meta.last });
320
- }
321
- }
322
- evictWarm(warm, cfg.maxWarmTools); // 载入即按上限裁剪
323
- }
324
- };
325
- const persistWarmState = () => {
326
- if (!warmEnabled) return;
327
- // merge 写:与 discoveries 共用 state 文件,整写会互抹(Standards 轴 P1)。
328
- const payload = { ...readState(activeStateFile, {}), warm: [...warm.entries()], ts: Date.now() };
329
- let ok = writeState(activeStateFile, payload);
330
- if (!ok) {
331
- // 首选目录不可写 → 降到 ~/.dsh/state/
332
- activeStateFile = fallbackStateFile();
333
- writeState(activeStateFile, payload);
334
- }
335
- };
336
- restoreWarm();
337
-
338
- // P0-④: 发现率统计(独立于 warm 门控——maxWarmTools=0 时统计照常工作)。
339
- // 与 warm 共用同一个 state 文件;落盘节流 30s(工具输出非注入面,频率低)。
340
- let discoveries = new Map();
341
- let lastPersistTs = 0;
342
- const restoreDiscoveries = () => {
343
- let cloud = readState(activeStateFile, null);
344
- if (!cloud) {
345
- activeStateFile = fallbackStateFile();
346
- cloud = readState(activeStateFile, null);
347
- }
348
- discoveries = deserializeDiscoveries(cloud && Array.isArray(cloud.discoveries) ? cloud.discoveries : null);
349
- };
350
- const persistDiscoveries = () => {
351
- const now = Date.now();
352
- if (now - lastPersistTs < 30000) return; // 节流:30s 内只写一次
353
- lastPersistTs = now;
354
- // merge 写:与 warm 共用 state 文件,整写会互抹(Standards 轴 P1)。
355
- const payload = { ...readState(activeStateFile, {}), discoveries: serializeDiscoveries(discoveries), ts: now };
356
- let ok = writeState(activeStateFile, payload);
357
- if (!ok) {
358
- activeStateFile = fallbackStateFile();
359
- writeState(activeStateFile, payload);
360
- }
361
- };
362
- restoreDiscoveries();
363
- if (discoveries.size > 0) {
364
- const top = serializeDiscoveries(discoveries, 5).map((e) => `${e.name}(${e.count})`).join(", ");
365
- logger.info("discovery stats restored: %d tools, top: %s", discoveries.size, top);
366
- }
367
-
368
- // P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
369
- const searchCache = createSearchCache(200, 60000);
370
-
371
- // serverOf is the module-level function defined above (mcp__server__tool →
372
- // "mcp__server", openhands_* → "openhands", plain native tools → "native").
373
- // P3-1 (2026-09-02): the previous apply-local copy (split("__").slice(0,2))
374
- // let plain tools be their own "server", diverging from catalogText grouping
375
- // and making sibling closure useless for native/openhands families. One
376
- // definition, one口径.
377
-
378
- // Pattern matching shared by deny (P0) and include (P2-1): exact name or
379
- // "prefix*" matches any tool starting with that prefix.
380
- function matchesAnyPattern(name, patterns) {
381
- if (!name || !patterns || patterns.length === 0) return false;
382
- const n = String(name);
383
- for (const p of patterns) {
384
- if (p.endsWith("*")) {
385
- if (n.startsWith(p.slice(0, -1))) return true;
386
- } else if (n === p) {
387
- return true;
388
- }
389
- }
390
- return false;
391
- }
392
- // deny config: exact name, or "prefix*" matches any tool starting with it.
393
- // Deny wins over core, include and the catalog (a denied tool is not listed).
394
- const denyPatterns = (cfg.deny || []).map((n) => String(n).trim()).filter(Boolean);
395
- const isDenied = (n) => matchesAnyPattern(n, denyPatterns);
396
-
397
- // Per-server top-K: rank the matched server's tools by BM25 relevance with
398
- // the matched intent keywords bridged into the query. When there is no
399
- // lexical signal at all, fall back to a stable name-order subset so the
400
- // intent route still helps. Used by both aliases and category routing.
401
- function addPerServerTopK(dyn, tools, serverPrefix, bridgedQuery, maxK) {
402
- const serverIndices = [];
403
- for (let i = 0; i < tools.length; i++) {
404
- if (tools[i].name.startsWith(serverPrefix)) serverIndices.push(i);
405
- }
406
- if (serverIndices.length === 0) return;
407
- const k = Math.min(maxK, serverIndices.length);
408
- let ranked = [];
409
- if (index) {
410
- ranked = search(index, bridgedQuery, serverIndices.length, {
411
- filter: (i) => tools[i].name.startsWith(serverPrefix),
412
- });
413
- }
414
- if (ranked.length === 0) {
415
- // No lexical signal at all → stable name order so the route still helps.
416
- serverIndices.sort((a, b) => (tools[a].name < tools[b].name ? -1 : 1));
417
- for (const i of serverIndices.slice(0, k)) dyn.add(tools[i].name);
418
- } else {
419
- for (const i of ranked.slice(0, k)) dyn.add(tools[i].name);
420
- }
421
- }
422
-
423
- function buildDynamicSet(tools, query) {
424
- const dyn = new Set();
425
- if (!query) return dyn;
426
- const q = query.toLowerCase();
427
- // P1-1: category wins over aliases. When a category key matches, only the
428
- // category's servers are routed per-server top-K; the aliases branch is
429
- // skipped. The generic BM25 leg is still merged as a union afterwards.
430
- const cat = matchCategory(q, categoryCfg);
431
- if (cat.matched) {
432
- for (const m of cat.matches) {
433
- addPerServerTopK(dyn, tools, m.server, `${query} ${m.keywords.join(" ")}`, 3);
434
- }
435
- } else {
436
- // aliases fallback — narrowed to a single representative tool per
437
- // matching server (topK=1). A whole-server injection cancels the fold
438
- // budget; the rest of the server stays discoverable via tools_search
439
- // and the grouped catalog.
440
- for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
441
- const matched = keywords.filter((k) => q.includes(String(k).toLowerCase()));
442
- if (matched.length === 0) continue;
443
- addPerServerTopK(dyn, tools, prefix, `${query} ${matched.join(" ")}`, 1);
444
- }
445
- }
446
- // Generic BM25 last as a union (result set unchanged vs. previous order).
447
- if (cfg.topK > 0 && index) {
448
- for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
449
- }
450
- return dyn;
451
- }
452
-
453
- // L4: `tools_search` — model-driven discovery of folded tools.
454
- if (cfg.toolSearchEnabled) {
455
- const outputSchema = {
456
- type: "object",
457
- additionalProperties: false,
458
- properties: {
459
- query: { type: "string", required: true },
460
- results: {
461
- type: "array",
462
- required: true,
463
- items: {
464
- type: "object",
465
- additionalProperties: false,
466
- properties: {
467
- name: { type: "string", required: true },
468
- description: { type: "string", required: true },
469
- server: { type: "string", required: true },
470
- },
471
- },
472
- },
473
- total: { type: "integer", required: true },
474
- fallback: { type: "boolean", description: "true = 零匹配,结果回退为按名字典序的轻目录" },
475
- },
476
- };
477
- const toolsSearch = defineTool({
478
- name: "tools_search",
479
- description:
480
- "Search the full tool catalog for a task. Use when the tools loaded " +
481
- "for this request may not cover what you need — e.g. a server-specific " +
482
- "capability — and you want to know if a matching tool exists. " +
483
- "Returns matching tool names and descriptions; you may then call any " +
484
- "returned tool directly by name.",
485
- parameters: {
486
- query: {
487
- type: "string",
488
- required: true,
489
- description: "Search keywords, e.g. 'openhands session' or 'context7 documentation'.",
490
- },
491
- limit: { type: "number", description: "Max results. Default 8." },
492
- },
493
- output: { schema: outputSchema, render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }] },
494
- isConcurrencySafe: () => true,
495
- async execute(args) {
496
- // 整体 fail-open 兜底(Spec 轴 P2):任何未来 helper 意外抛错 → 空结果
497
- // 而非硬失败,保持"折叠绝不误伤能力"的底线。
498
- try {
499
- const q = String(args.query || "");
500
- const lim = args.limit ?? 8;
501
- if (!index) return { query: q, results: [], total: 0 };
502
- // P0-②: 同 query 内容指纹的结果缓存——重复搜索返回同一结果集(漂移的
503
- // 最小修)。进程级共享(非单会话隔离);空 query("*" 语义)不走缓存。
504
- const cacheKey = `${lim}|${q}`;
505
- if (q) {
506
- const cached = searchCache.get(cacheKey);
507
- if (cached) return cached;
508
- }
509
- // P0-③: 中文意图→英文同义词扩展(只加词不改原词;精确名匹配用原 q)。
510
- const eq = cfg.queryExpandEnabled !== false ? expandQuery(q) : q;
511
- // v0.2.1: exact-name > name-substring > BM25 priority (deterministic
512
- // fallback chain, borrowed from Hermes-style tool search).
513
- const bm25Hits = prioritizeExact(
514
- allTools,
515
- q,
516
- search(index, eq, lim * 2).map((i) => ({ id: allTools[i].name })),
517
- );
518
- let fused = bm25Hits;
519
- // Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
520
- // Any failure (Ollama down / timeout / cache miss on error) degrades
521
- // to BM25-only — the hybrid must never be slower or worse than today.
522
- if (cfg.semanticEnabled !== false) {
523
- try {
524
- const sem = await getSemanticIndex();
525
- if (sem?.available) {
526
- const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
527
- const semHits = await searchSemantic(sem.vectors, docs, eq);
528
- fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim);
529
- }
530
- } catch (e) {
531
- logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
532
- }
533
- }
534
- const byName = new Map(allTools.map((t) => [t.name, t]));
535
- let results = fused.map((h) => {
536
- const t = byName.get(h.id) || {};
537
- return {
538
- name: h.id,
539
- description: (t.description || "").slice(0, 160),
540
- server: serverOf(h.id),
541
- };
542
- });
543
- // P0-①: 零匹配回退轻目录(name+80字描述,名字典序)——根治「搜词不对
544
- // 就以为没有该能力」。fallbackCatalogSize=0 关闭(返回空=旧行为)。
545
- // total 在回退时=全量目录大小(而非列出条数),避免模型误以为目录总容量=limit。
546
- let fallback = false;
547
- if (results.length === 0 && cfg.fallbackCatalogSize > 0) {
548
- results = fallbackCatalog(allTools, cfg.fallbackCatalogSize, serverOf);
549
- fallback = true;
550
- }
551
- // v0.2.2: 被搜到的工具名进入 warm 集合,下一轮注入完整 schema。
552
- touchWarmNames(results.map((r) => r.name));
553
- // P0-④: 发现率统计(节流落盘,低频工具名单反哺描述改进)。
554
- recordDiscoveries(discoveries, results.map((r) => r.name));
555
- persistDiscoveries();
556
- const out = {
557
- query: q,
558
- results,
559
- total: fallback ? allTools.length : results.length,
560
- ...(fallback ? { fallback: true } : {}),
561
- };
562
- if (q) searchCache.set(cacheKey, out);
563
- return out;
564
- } catch (e) {
565
- logger.warn("tools_search failed (%s) — empty results", e?.message);
566
- return { query: String(args.query || ""), results: [], total: 0 };
567
- }
568
- },
569
- });
570
- try {
571
- ctx.tools.register(toolsSearch);
572
- logger.info("tools_search registered");
573
- } catch (e) {
574
- logger.warn("tools_search register failed: %s", e?.message);
575
- }
576
- }
577
-
578
- // L4: `tools_schema` — model-driven full-schema discovery for one tool.
579
- // Complementary to tools_search: search narrows candidates, this returns the
580
- // exact parameter schema + full description of one named tool.
581
- // RED LINE (C3): output MUST be `{ schema: { type: "json" } }` — the return
582
- // value is an arbitrary tool schema, and a strict object schema with
583
- // additionalProperties:false would fail the host's "declared property"
584
- // validation (ToolOutputError). The `type:"json"` annotation schema only
585
- // checks lossless JSON (C4).
586
- if (cfg.schemaToolEnabled) {
587
- const schemaTool = defineTool({
588
- name: "tools_schema",
589
- description:
590
- "Get the complete parameter schema of one or more tools by name. Use after " +
591
- "tools_search narrows candidates, or before calling a tool whose exact " +
592
- "arguments you need. Returns the full JSON schema of parameters plus the " +
593
- "full description; sibling tools from the same server are listed alongside " +
594
- "(lightweight) so you can batch-expand what you need.",
595
- parameters: {
596
- name: {
597
- type: "string",
598
- description: "Exact tool name, e.g. 'mcp__viking__remember' (either name or names).",
599
- },
600
- names: {
601
- type: "array",
602
- items: { type: "string" },
603
- description: "Batch: exact tool names to expand in one call (either name or names).",
604
- },
605
- closure: {
606
- type: "boolean",
607
- description: "Include lightweight sibling tools from the same server (default true).",
608
- },
609
- },
610
- output: {
611
- schema: { type: "json" },
612
- render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }],
613
- },
614
- isConcurrencySafe: () => true,
615
- async execute(args) {
616
- // P1: 批量展开(names 数组)+ 同 server 兄弟闭包(closureSize 条轻目录)。
617
- // 输出契约全部在 buildSchemaResponse(纯函数,lib/ux.js 可单测)。
618
- const res = buildSchemaResponse(allTools, args, { closureSize: cfg.closureSize, serverOf });
619
- // v0.2.2: 展开过 schema 的工具名进入 warm 集合,下一轮保持注入。
620
- touchWarmNames(
621
- Array.isArray(res.results) ? res.results.map((r) => r.name) : res.found && res.name ? [res.name] : [],
622
- );
623
- return res;
624
- },
625
- });
626
- try {
627
- ctx.tools.register(schemaTool);
628
- logger.info("tools_schema registered");
629
- } catch (e) {
630
- logger.warn("tools_schema register failed: %s", e?.message);
631
- }
632
- }
633
-
634
- disposers.push(
635
- ctx.on("system-prompt/assemble", async (assembly, context, next) => {
636
- const t0 = Date.now();
637
- try {
638
- const assembled = await next();
639
- const assembledTools = assembled.tools || [];
640
- if (!Array.isArray(assembledTools)) return assembled;
641
- // P2-1 include whitelist + deny config remove tools from the injected
642
- // surface BEFORE folding. Semantics: include non-empty → whitelist mode,
643
- // only matching tools enter the pool (injection surface + catalog +
644
- // tools_search/tools_schema search scope); execution is NOT gated here
645
- // (C5 — pre-execute only checks deny + firewall). Deny still wins.
646
- // Meta tools are exempt from include so a whitelist can never hide the
647
- // discovery tools themselves (that would break the always-visible loop).
648
- const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
649
- const tools = assembledTools.filter(
650
- (t) =>
651
- !isDenied(t?.name) &&
652
- (isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns)),
653
- );
654
- if (tools.length === 0) return { ...assembled, tools };
655
-
656
- const agentId = context?.agent?.id || "default";
657
- const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
658
- // v0.2.1: hot = sliding-window decay (hotWindowDays). Tools not called
659
- // inside the window lose their historical heat — the old code promoted
660
- // forever, which slowly nullified the fold.
661
- const hotNames = new Set(
662
- Object.keys(feedback.calls || {}).filter((n) =>
663
- isHotTool(feedback.calls, n, cfg.hotThreshold, cfg.hotWindowDays * 86400000)),
664
- );
665
-
666
- const stamp = tools.map((t) => t.name).join("\u0001");
667
- if (indexStamp !== stamp) {
668
- // V4 document expansion (measured 0.1.7→0.1.8): the tool corpus is
669
- // pure-English, so Chinese queries always get df=0 and BM25 scores
670
- // nothing (measured recall 0/26). Inject per-tool aliases from
671
- // cfg.aliases (prefix-matched) into the index text — CN recall
672
- // 0%→88%, EN +15pp, both from the same expansion.
673
- index = buildIndex(
674
- tools.map((t) => {
675
- const aliasWords = [];
676
- for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
677
- if (t.name.startsWith(prefix)) aliasWords.push(...keywords);
678
- }
679
- return {
680
- id: t.name,
681
- text: `${t.name} ${t.description || ""} ${aliasWords.join(" ")}`,
682
- };
683
- }),
684
- );
685
- indexStamp = stamp;
686
- allTools = tools;
687
- logger.info("index rebuilt: %d tools (doc-expansion enabled)", tools.length);
688
-
689
- }
690
-
691
- const query = extractQuery(context);
692
- const dynNames = buildDynamicSet(tools, query);
693
-
694
- // v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
695
- const warmNamesSet = new Set(
696
- [...warm.keys()].filter((n) => tools.some((t) => t.name === n)),
697
- );
698
-
699
- // L5: stable ordering — core by config order, hot by name, dyn by name.
700
- // A deterministic byte order keeps the prompt prefix cacheable.
701
- const coreTools = tools.filter((t) => coreNames.has(t.name));
702
- const hotTools = tools
703
- .filter((t) => !coreNames.has(t.name) && hotNames.has(t.name))
704
- .sort((a, b) => (a.name < b.name ? -1 : 1));
705
- const dynTools = tools
706
- .filter(
707
- (t) =>
708
- !coreNames.has(t.name) &&
709
- !hotNames.has(t.name) &&
710
- !warmNamesSet.has(t.name) &&
711
- dynNames.has(t.name),
712
- )
713
- .sort((a, b) => (a.name < b.name ? -1 : 1));
714
-
715
- // v0.2.2: warm = 上一轮被 tools_search/schema 发现或折叠后被调用的工具。
716
- // 属主动晋升(接近 hot),完整 schema 注入,不作为 budget 可降级段。
717
- const warmTools = tools
718
- .filter(
719
- (t) =>
720
- !coreNames.has(t.name) &&
721
- !hotNames.has(t.name) &&
722
- warmNamesSet.has(t.name),
723
- )
724
- .sort((a, b) => (a.name < b.name ? -1 : 1));
725
-
726
- // L1: schema compression + description normalization on the dynamic
727
- // segment only. core/hot/warm never pass through this pipe (P0 red line).
728
- const trimmed =
729
- effectiveLevel !== "off" || cfg.normalizeDescriptions
730
- ? dynTools.map((t) => {
731
- let out = t;
732
- if (cfg.normalizeDescriptions && typeof t.description === "string") {
733
- out = { ...out, description: normalizeDescription(t.description) };
734
- }
735
- return effectiveLevel !== "off"
736
- ? compressTool(out, { level: effectiveLevel })
737
- : out;
738
- })
739
- : dynTools;
740
-
741
- // v0.2.2 分级披露预算:动态段(trimmed)是唯一可降级段。core/hot/warm
742
- // 作为 protected 恒层级 1。tierForBudget 返回每个可见名字的降级层级;
743
- // 层级 2/3 组装压缩形态,层级 4 不再注入(由 catalog 兜底发现)。
744
- let visible = [...coreTools, ...hotTools, ...warmTools, ...trimmed];
745
- if (cfg.disclosureBudget > 0) {
746
- const protectedNames = new Set([
747
- ...coreNames,
748
- ...hotNames,
749
- ...warmNamesSet,
750
- // meta 工具永远完整可见
751
- "tools_search",
752
- "tools_schema",
753
- ]);
754
- const tiers = tierForBudget(visible, cfg.disclosureBudget, protectedNames);
755
- // Effective budget = min(disclosureBudget, context estimate). We do not
756
- // separately measure context here, so the hard disclosureBudget stands.
757
- // Rebuild visible honoring per-tool tiers (they are already ordered).
758
- visible = visible.map((t) => {
759
- const tier = tiers.byName[t.name] || 1;
760
- if (tier === 1) return t; // 完整 schema
761
- if (tier === 2) {
762
- // name + description(描述压缩至 100 字符)
763
- return {
764
- name: t.name,
765
- description: String(t.description || "").slice(0, 100),
766
- };
767
- }
768
- if (tier === 3) {
769
- // 仅 name(一行一个)
770
- return { name: t.name };
771
- }
772
- return null; // 层级 4:折叠进 catalog,不注入
773
- }).filter(Boolean);
774
- }
775
-
776
- // The meta-tools must always stay visible or the model can never ask
777
- // for folded tools (they would be hidden by their own fold).
778
- const META_TOOLS = [
779
- { name: "tools_search", enabled: cfg.toolSearchEnabled },
780
- { name: "tools_schema", enabled: cfg.schemaToolEnabled },
781
- ];
782
- for (const m of META_TOOLS) {
783
- if (m.enabled && !visible.some((t) => t.name === m.name)) {
784
- const meta = tools.find((t) => t.name === m.name);
785
- if (meta) visible.push(meta);
786
- }
787
- }
788
-
789
- // v0.2.2 fail-open 硬保证(借鉴 fan56/dsh-mcp-adapter):启用的发现
790
- // 元工具仍缺失(注册失败/名冲突/部分启动)→ 折叠后工具不可发现,
791
- // 比不折叠更糟——显式回退官方全量工具列表,绝不静默吞掉发现路径。
792
- const missingMeta = missingMetaTools(
793
- new Set(visible.map((t) => t.name)),
794
- { tools_search: cfg.toolSearchEnabled, tools_schema: cfg.schemaToolEnabled },
795
- );
796
- if (missingMeta.length > 0) {
797
- logger.warn(
798
- "fail-open: meta tool(s) %s missing after fold — falling back to full tool list",
799
- missingMeta.join(","),
800
- );
801
- return next();
802
- }
803
-
804
- const visibleNames = visible.map((t) => t.name);
805
- lastVisible = { agentId, names: visibleNames, ts: Date.now() };
806
-
807
- let sections = assembled.sections;
808
- if (cfg.catalogEnabled) {
809
- const folded = tools.filter(
810
- (t) =>
811
- !isMetaTool(t.name) &&
812
- !coreNames.has(t.name) &&
813
- !hotNames.has(t.name) &&
814
- !warmNamesSet.has(t.name) &&
815
- !dynNames.has(t.name),
816
- );
817
- if (folded.length > 0) {
818
- sections = [...(assembled.sections || []), {
819
- name: "tool-folder-catalog",
820
- text: catalogText(folded),
821
- }];
822
- }
823
- }
824
-
825
- logger.info(
826
- "inject=%d/%d agent=%s dyn=%d hot=%d q=%s",
827
- visible.length, tools.length, agentId, dynNames.size, hotNames.size,
828
- query ? query.slice(0, 40) : "(none)",
829
- );
830
-
831
- return { ...assembled, tools: visible, sections };
832
- } catch (e) {
833
- logger.warn("fold failed (%s) — falling back to full tool list", e?.message);
834
- return next();
835
- } finally {
836
- const dt = Date.now() - t0;
837
- if (dt > cfg.maxFoldMs) logger.warn("assemble fold took %dms (> %dms)", dt, cfg.maxFoldMs);
838
- }
839
- }),
840
- );
841
-
842
- // L3 + L4: heat feedback, coverage recording, and folded-call detection.
843
- // Bound to `tools/post-execute` — payload = (exec, result), exec.name /
844
- // exec.arguments are available. (`agent/pre-step` was the original hook but
845
- // its payload is { messages, ...position, signal } — no toolCalls — so the
846
- // feedback loop had no data. Verified against dsh-agent-loop preStep().)
847
- // This listener is deliberately side-effect only: it always returns next()
848
- // and never throws, so a feedback bug can never turn a tool result into
849
- // an isError (postExecute wraps a throwing listener as failure).
850
- disposers.push(
851
- ctx.on("tools/post-execute", (exec, _result, next) => {
852
- try {
853
- const name = exec?.name;
854
- if (!name) return next();
855
-
856
- // P2-2 toonify (opt-in, default off): compact long JSON text blocks in
857
- // the result (drop empty fields + truncate long strings). Only fires
858
- // when the text block is >2000 chars AND parses as JSON; any failure
859
- // leaves the original block untouched — a compaction bug can never
860
- // corrupt a tool result.
861
- if (cfg.toonifyResults && _result && Array.isArray(_result.content)) {
862
- _result.content = _result.content.map((block) => {
863
- if (
864
- block?.type !== "text" ||
865
- typeof block.text !== "string" ||
866
- block.text.length <= 2000
867
- ) {
868
- return block;
869
- }
870
- try {
871
- const v = JSON.parse(block.text);
872
- const compact = JSON.stringify(toonifyValue(v), null, 2);
873
- return compact.length < block.text.length ? { ...block, text: compact } : block;
874
- } catch {
875
- return block;
876
- }
877
- });
878
- }
879
-
880
- // Heat counting (L4 feedback). v0.2.1: upgrade to { n, ts } so the
881
- // hot gate can decay stale heat; legacy number entries are kept
882
- // readable by isHotTool (ts=0 → outside any positive window).
883
- const prev = feedback.calls[name];
884
- feedback.calls[name] = {
885
- n: (typeof prev === "number" ? prev : prev?.n || 0) + 1,
886
- ts: Date.now(),
887
- };
888
-
889
- // Firewall: record the call sequence and flag exfil chains.
890
- if (cfg.firewallEnabled !== false) {
891
- chain.record(name, execArgumentsText(exec?.arguments));
892
- const exfil = chain.checkChain();
893
- if (exfil) {
894
- logger.warn(
895
- "[firewall] %s: %s",
896
- exfil.label, exfil.chain.join(" → "),
897
- );
898
- feedback.firewall = feedback.firewall || [];
899
- feedback.firewall.push({ ts: Date.now(), ...exfil });
900
- if (feedback.firewall.length > 20) {
901
- feedback.firewall.splice(0, feedback.firewall.length - 20);
902
- }
903
- }
904
- }
905
-
906
- // Folded-call detection: did the model call something we hid?
907
- const cov = selectionCoverage(lastVisible.names, [name]);
908
- if (cov.uncovered.length > 0) {
909
- logger.warn(
910
- "folded tools called (coverage=%.2f): %s — promoting via heat",
911
- cov.coverage, cov.uncovered.join(", "),
912
- );
913
- // v0.2.2: 折叠后仍被调用的工具名也进 warm,下一轮避免再折叠。
914
- touchWarmNames(cov.uncovered);
915
- }
916
-
917
- // L3: record the last 50 sessions for offline evaluation.
918
- feedback.sessions.push({
919
- ts: Date.now(),
920
- agent: lastVisible.agentId,
921
- visible: lastVisible.names,
922
- calls: [name],
923
- coverage: cov.coverage,
924
- uncovered: cov.uncovered,
925
- });
926
- if (feedback.sessions.length > 50) feedback.sessions.splice(0, feedback.sessions.length - 50);
927
-
928
- persistFeedback(fbPath, feedback, logger);
929
- } catch (e) {
930
- logger.warn("post-execute hook error (%s) — best-effort", e?.message);
931
- }
932
- return next();
933
- }),
934
- );
935
-
936
- // Firewall hard-block + deny config: refuse execution at tools/pre-execute.
937
- // Host contract (dsh-tools prepareExecution): the waterfall's gate is the
938
- // last value; return { kind: "deny", reason } to refuse, or next() to allow.
939
- // exec carries { callId, name, arguments, agent, signal } — no .deny().
940
- disposers.push(
941
- ctx.on("tools/pre-execute", (exec, next) => {
942
- try {
943
- const name = exec?.name;
944
-
945
- // deny config: refuse execution regardless of firewallEnabled.
946
- if (isDenied(name)) {
947
- logger.warn("[deny] tool disabled by config: %s", name);
948
- return { kind: "deny", reason: `[tool-folder] 工具 ${name} 已被禁用(deny 配置)。` };
949
- }
950
-
951
- if (cfg.firewallEnabled !== false) {
952
- // 2026-08-30 修复:高危硬拦只对「执行类工具」生效。
953
- // 原实现对所有工具的 arguments 跑高危正则,导致内容类工具
954
- // (write/edit/read 等参数是文件内容/文本)只要含危险命令的
955
- // 字面量(如讨论、测试、文档中的示例)就被误拦。
956
- // 执行类工具的参数才是真实要执行的命令,需要硬拦。
957
- if (isExecTool(name)) {
958
- const argvText = execArgumentsText(exec?.arguments);
959
- const v = verdict(argvText);
960
- if (v?.blocked) {
961
- logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
962
- return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
963
- }
964
- }
965
- }
966
- } catch (e) {
967
- logger.warn("pre-execute hook error (%s) — best-effort", e?.message);
968
- }
969
- return next();
970
- }),
971
- );
972
-
973
- // Cleanup on plugin unload: no leaked listeners.
974
- return () => {
975
- for (const d of disposers) {
976
- try {
977
- d();
978
- } catch {
979
- /* already disposed */
980
- }
981
- }
982
- };
983
- }
1
+ /**
2
+ * dsh-tool-folder — fold the DSH tool surface down to what each request needs.
3
+ *
4
+ * Six layers (L0-L5), all implemented here:
5
+ * L1 schema compression — conservative description trimming for dyn tools
6
+ * L2 dynamic loading — per-agent core + BM25 top-K + CN-alias servers
7
+ * L3 selection metrics — selectionCoverage / retrievalMetrics recorded
8
+ * L4 execution side — folded tools stay executable + heat promotion +
9
+ * `tools_search` tool for on-demand discovery
10
+ * L5 cache discipline — stable ordering (core by config, hot/dyn sorted
11
+ * by name) so the prompt prefix stays byte-stable
12
+ * (L0 description rewrite is a separate offline batch — see README)
13
+ *
14
+ * Mechanism (verified against DSH 0.1.1-rc.1 sources, 2026-08-24):
15
+ * assembly = { sections, contexts, tools, variables } and the waterfall
16
+ * `system-prompt/assemble` lets a plugin REPLACE the object authoritatively;
17
+ * buildRequest uses assembly.tools as the ONLY source of the API tools field.
18
+ * Execution is NOT gated on the current turn's tools, so folding never
19
+ * removes capability.
20
+ * `tools/pre-execute` — listener receives (exec, next); return
21
+ * { kind: "deny", reason } to refuse, or call next() to allow.
22
+ * exec = { callId, name, arguments, agent, signal } (no .deny() method).
23
+ * `tools/post-execute` — listener receives (exec, result, next); fires once
24
+ * per executed tool. exec.name/exec.arguments feed the L3/L4 feedback
25
+ * loop. (`agent/pre-step` carries { messages, position, signal } only —
26
+ * no toolCalls — so it is useless for feedback.)
27
+ *
28
+ * Safety: any error falls back to the untouched assembly. Performance: BM25
29
+ * in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
30
+ */
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import z from "@deepseek-ai/schemastery";
34
+ import { buildIndex, search, tokenize } from "./bm25.js";
35
+ import { compressTool, normalizeDescription } from "./schema.js";
36
+ import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, missingMetaTools } from "./metrics.js";
37
+ import { tierForBudget } from "./budget.js";
38
+ import { readState, writeState } from "./storage.js";
39
+ import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
40
+ // v0.3.1:增强词表扩展(中英双向 + 反向别名)。与 ux.js 的 EXPAND 并存互补:
41
+ // ux 的字典是「一个中文词 → 一个英文词」,这里是「一个意图词 → 多个中英同义词」。
42
+ import { expandQuery as expandQueryTokens } from "./expand.js";
43
+ // v0.3.1:静态嵌入语义腿(离线、零推理)。构建脚本 tools/build-static-embed.py。
44
+ import { fileURLToPath } from "node:url";
45
+ import { openStaticEmbed, cosine } from "./static-embed.js";
46
+ import { ChainGuard, verdict, isExecTool } from "./chainguard.js";
47
+ import { matchCategory } from "./category.js";
48
+ import { toonifyValue } from "./toonify.js";
49
+ import {
50
+ configureSemantic,
51
+ buildSemanticIndex,
52
+ searchSemantic,
53
+ searchSemanticCached,
54
+ embedQueryCached,
55
+ getCachedQueryVector,
56
+ rrfFuse,
57
+ adaptiveBmWeight,
58
+ DEFAULT_SEM_WEIGHT,
59
+ DEFAULT_BM_WEIGHT,
60
+ } from "./semantic.js";
61
+ import { rerankByBody, toolBodyText } from "./rerank.js";
62
+ import { remoteEmbedState } from "./embed-remote.js";
63
+ import { defineTool } from "@deepseek-ai/dsh-tools";
64
+
65
+ export const name = "tool-folder";
66
+ export const inject = ["systemPrompt", "agents", "tools"];
67
+
68
+ /**
69
+ * Schemastery configuration schema — renders the plugin's settings form in the
70
+ * DSH settings UI (per cordis `runtime.Config["~standard"]`). Mirrors DEFAULTS
71
+ * below; the UI exposes every toggle/knob this plugin has.
72
+ * Same pattern as @deepseek-ai/dsh-tool-todo (z.object + .default()).
73
+ */
74
+ const COMPRESS_LEVELS = ["off", "light", "standard", "aggressive"];
75
+ export const Config = z.object({
76
+ enabled: z.boolean().default(true).description("总开关:启用工具折叠"),
77
+ core: z.array(z.string()).default([]).description("每轮恒显示的工具(平台核心,永不折叠)"),
78
+ perAgent: z.dict(z.object({
79
+ core: z.array(z.string()).default([]).description("该 agent 的常驻工具覆盖(未列出 agent 回退到全局 core)"),
80
+ })).default({}).description("按 agent 覆盖常驻工具:{\"standard\": {\"core\": [...]}}"),
81
+ deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
82
+ topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
83
+ hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
84
+ hotWindowDays: z.number().min(0).max(365).default(3)
85
+ .description("hot 热度的滑动窗口(天):窗口内达标才常驻,超窗自动衰减;0=永不过期"),
86
+ catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
87
+ compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
88
+ compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
89
+ schemaToolEnabled: z.boolean().default(true).description("注册 tools_schema 元工具:按名称展开任意工具的完整参数 schema"),
90
+ normalizeDescriptions: z.boolean().default(false).description("描述标准化:注入消毒+首句提取+300 字符(只作用于动态段)"),
91
+ category: z.dict(z.array(z.string())).default({}).description("意图路由:{\"记忆/回忆\": [\"mcp__viking\"]},优先级 category > aliases > BM25"),
92
+ include: z.array(z.string()).default([]).description("白名单:[\"mcp__openhands*\"],空=全部(deny 仍优先,meta 工具豁免)"),
93
+ toonifyResults: z.boolean().default(false).description("长 JSON 结果紧凑化(删空字段/截长串,默认关)"),
94
+ toolSearchEnabled: z.boolean().default(true).description("注册 tools_search 元工具:检索被折叠的工具目录"),
95
+ firewallEnabled: z.boolean().default(true).description("ChainGuard 防火墙:高危命令硬拦截+外泄链检测"),
96
+ maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
97
+ feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
98
+ aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
99
+ semanticEnabled: z.boolean().default(true).description("语义检索腿:tools_search 用 BM25+语义 加权 RRF 混合(本地静态嵌入优先 → 远程 embedding → 本地 Ollama → 纯 BM25)"),
100
+ staticEmbedDir: z.string().default("").description("静态嵌入目录(含 vocab.json/vectors.bin/meta.json)。留空则用插件自带 lib/static-embed;目录不存在时该腿自动跳过"),
101
+ ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(远程 embedding 不可用时的本地兜底)"),
102
+ embedModel: z.string().default("bge-m3").description("本地 Ollama embedding 模型(兜底腿)"),
103
+ embedTimeoutMs: z.number().min(100).default(12000).description("embedding 请求超时(毫秒)"),
104
+ embedApiBase: z.string().default("https://ark.cn-beijing.volces.com/api/plan/v3").description("远程 embedding 端点(OpenAI 兼容;默认火山方舟)"),
105
+ embedApiModel: z.string().default("doubao-embedding-vision").description("远程 embedding 模型"),
106
+ embedApiKey: z.string().default("").description("远程 embedding key;留空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY"),
107
+ semanticWeight: z.number().min(0).max(20).default(3).description("RRF 中**远程**语义腿相对 BM25 的权重:1=等权,3=偏语义(默认),0=纯 BM25"),
108
+ staticWeight: z.number().min(0).max(20).default(0.15).description("RRF 中**本地静态嵌入**腿相对 BM25 的权重,默认 0.15。静态腿只负责补词法查不到的坑,压过词法会引入噪声(实测:留出集 w=0.15 → recall@3 0.621,w=2 → 0.517)"),
109
+ adaptiveBm: z.boolean().default(true).description("自适应 BM25 权重:查询里有真实英文锚点(工具名/server/关键词)时 BM25 权重 1,纯中文查询降到 bmWeight(实测 6/18→11/18)"),
110
+ bmWeight: z.number().min(0).max(5).default(0.25).description("纯中文查询下 BM25 的 RRF 权重(adaptiveBm=false 时固定用这个值)"),
111
+ bodyRerank: z.boolean().default(false).description("正文精排:在召回候选集内用完整描述+参数 schema 重排(借鉴 SkillRouter 两阶段检索)。实测本语料 Hit@1 持平、Hit@3 -1,故默认关"),
112
+ bodyRerankWeight: z.number().min(0).max(1).default(0.45).description("正文精排权重:final=(1-w)·元数据名次 + w·正文覆盖。v0.3.1 由 0.35 调到 0.45:eval/sweep.mjs 扫描 40 条标注查询,0.45 得 82.5% top-1(0.35 为 80.0%),0.50 起开始掉"),
113
+ bodyRerankTopN: z.number().min(1).max(50).default(12).description("参与正文重排的候选数(不扩大候选,只在召回集内重排)"),
114
+ semanticDynamic: z.boolean().default(true).description("语义腿参与每轮自动注入:只用已缓存的查询向量,零额外延迟(首次查询由后台预热)"),
115
+ debugDump: z.string().default("").description("调试:把当前工具语料(名/描述/schema)dump 到该路径,用于离线评测(空=关闭)"),
116
+ disclosureBudget: z.number().min(0).max(200000).default(0)
117
+ .description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
118
+ maxWarmTools: z.number().min(0).max(50).default(0)
119
+ .description("warm LRU 上限:tools_search/schema 发现或折叠后被调用的工具进入 warm 集合,下一轮注入(完整 schema);达上限逐出最久未用;0=关闭(默认关,无配置升级逐字节等价,显式开启才启用)"),
120
+ fallbackCatalogSize: z.number().min(0).max(200).default(30)
121
+ .description("P0: tools_search 零匹配时回退的轻目录条数(name+80字描述,名字典序);0=关闭回退(返回空=旧行为)"),
122
+ queryExpandEnabled: z.boolean().default(true)
123
+ .description("P0: 中文意图→英文同义词扩展(只加词不改原词,扩大 BM25/语义召回;精确名匹配不受影响)"),
124
+ closureSize: z.number().min(0).max(20).default(0)
125
+ .description("P1: tools_schema 展开时附带同 server 兄弟工具的轻目录条数(name+120字描述,名字典序);0=关闭(默认关=无配置升级输出逐字段等价,显式开启才启用,与 maxWarmTools 先例一致)"),
126
+ });
127
+
128
+ const DEFAULTS = {
129
+ enabled: true,
130
+ perAgent: {},
131
+ core: [],
132
+ deny: [], // never inject + refuse execution (exact name or "prefix*")
133
+ topK: 6,
134
+ hotThreshold: 3,
135
+ hotWindowDays: 3, // v0.2.1: sliding-window heat decay (0 = never expire)
136
+ catalogEnabled: true, // safety net: folded tools stay discoverable by name
137
+ compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
138
+ compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
139
+ schemaToolEnabled: true, // P0-1: register the tools_schema meta tool
140
+ normalizeDescriptions: false, // P1-2: sanitize+first-sentence+300 dyn descriptions
141
+ category: {}, // P1-1: { "记忆/回忆": ["mcp__viking"], ... } intent routing
142
+ include: [], // P2-1: whitelist ("prefix*" or exact name); empty = all (minus deny)
143
+ toonifyResults: false, // P2-2: compact long JSON results (default off)
144
+ toolSearchEnabled: true,
145
+ firewallEnabled: true, // ChainGuard: high-risk hard block + exfil chain warn
146
+ maxFoldMs: 50,
147
+ feedbackFile: "",
148
+ aliases: {
149
+ "mcp__viking": ["记忆", "记住", "回忆", "remember", "memory"],
150
+ "mcp__openhands": ["写代码", "编程", "编码", "开发", "coding", "code", "openhands", "委派", "delegate"],
151
+ "mcp__context7": ["文档", "api 文档", "库文档", "documentation", "docs", "context7"],
152
+ "mcp__deeptutor": ["学习", "课程", "教学", "tutor", "study", "learn"],
153
+ "mcp__reasonix": ["推理", "复杂任务", "深度思考", "reasoning", "reasonix"],
154
+ "mcp__open-design": ["设计", "design"],
155
+ "mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
156
+ },
157
+ semanticEnabled: true, // v0.3.1: 静态嵌入优先 → 远程 → Ollama → BM25
158
+ staticEmbedDir: "", // 空 = <插件目录>/lib/static-embed
159
+ ollamaBase: "http://127.0.0.1:11434",
160
+ embedModel: "bge-m3",
161
+ embedTimeoutMs: 12000, // v0.3.0: 远程 embedding 首页可能较慢,8s 偏紧
162
+ embedApiBase: "https://ark.cn-beijing.volces.com/api/plan/v3", // v0.3.0 远程语义腿
163
+ embedApiModel: "doubao-embedding-vision",
164
+ embedApiKey: "", // 空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY
165
+ semanticWeight: 3, // v0.3.0: RRF 远程语义权重(1=等权)
166
+ staticWeight: 0.15, // v0.3.2: 本地静态嵌入权重,压测最优(1:0.15)
167
+ adaptiveBm: true, // v0.3.0: 纯中文查询自动降 BM25 权重
168
+ bmWeight: 0.25, // v0.3.0: 纯中文查询下的 BM25 权重(实测 1:1 融合只有 8/18)
169
+ bodyRerank: false, // v0.3.0: 实测 Hit@1 持平 / Hit@3 -1,默认关,可显式开
170
+ bodyRerankWeight: 0.45,
171
+ bodyRerankTopN: 12,
172
+ semanticDynamic: true, // v0.3.0: 语义腿参与自动注入(只用缓存查询向量)
173
+ debugDump: "", // v0.3.0: 工具语料 dump(离线评测用);也认 DSH_TOOL_FOLDER_DUMP 环境变量
174
+ disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
175
+ maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
176
+ fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
177
+ queryExpandEnabled: true, // P0: 中文→英文 query 扩展
178
+ closureSize: 0, // P1: tools_schema 同 server 兄弟闭包(默认关=逐字段等价,显式开启)
179
+ };
180
+
181
+ function feedbackPath(cfg) {
182
+ if (cfg.feedbackFile) return cfg.feedbackFile;
183
+ // Host-side plugins run in the real Node runtime (not the dynamic sandbox),
184
+ // so process.env is available. Guarded anyway: fall back to a relative path.
185
+ try {
186
+ const home = process.env.DSH_HOME || "";
187
+ if (home) return `${home}/logs/tool-folder/feedback.json`;
188
+ return `${process.env.APPDATA || "."}/dsh/logs/tool-folder/feedback.json`;
189
+ } catch {
190
+ return "tool-folder/feedback.json";
191
+ }
192
+ }
193
+
194
+ function loadFeedback(file, log) {
195
+ try {
196
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
197
+ } catch (e) {
198
+ log?.warn?.("feedback load failed (%s) — starting empty", e?.message);
199
+ }
200
+ return { calls: {}, sessions: [] };
201
+ }
202
+
203
+ function persistFeedback(file, fb, log) {
204
+ try {
205
+ fs.mkdirSync(path.dirname(file), { recursive: true });
206
+ fs.writeFileSync(file, JSON.stringify(fb, null, 2), "utf8");
207
+ } catch (e) {
208
+ log?.warn?.("feedback persist failed (%s) — non-fatal", e?.message);
209
+ }
210
+ }
211
+
212
+ /** Serialize an exec.arguments payload (object) into text for firewall verdicts. */
213
+ function execArgumentsText(args) {
214
+ if (args == null) return "";
215
+ if (typeof args === "string") return args;
216
+ try {
217
+ return JSON.stringify(args);
218
+ } catch {
219
+ return String(args);
220
+ }
221
+ }
222
+
223
+ function extractQuery(context) {
224
+ const signal = context && context.signal;
225
+ if (!signal) return "";
226
+ try {
227
+ const s = signal.userMessage?.content ?? signal.query ?? signal.prompt;
228
+ if (typeof s === "string") return s;
229
+ if (Array.isArray(signal.messages)) {
230
+ for (let i = signal.messages.length - 1; i >= 0; i--) {
231
+ const m = signal.messages[i];
232
+ if (m && m.role === "user" && typeof m.content === "string") return m.content;
233
+ }
234
+ }
235
+ } catch {
236
+ /* unknown shape */
237
+ }
238
+ return "";
239
+ }
240
+
241
+ function serverOf(name) {
242
+ // "mcp__github__get_repo" → "mcp__github"; "openhands_create_conversation" → "openhands";
243
+ // native tools with underscores ("read_file", "search_for_pattern") → "native".
244
+ const m = name.match(/^(mcp__[^_]+(?:_[^_]+)?)/);
245
+ if (m) return m[1];
246
+ if (/^openhands_/.test(name)) return "openhands";
247
+ return "native";
248
+ }
249
+
250
+ function summarizeCaps(tools) {
251
+ // Pull the first few distinct capability keywords from descriptions.
252
+ const seen = new Set();
253
+ const caps = [];
254
+ for (const t of tools) {
255
+ const d = (t.description || "").replace(/\s+/g, " ").trim();
256
+ const head = d.split(/[—,;:]/)[0].slice(0, 60);
257
+ if (head && !seen.has(head)) {
258
+ seen.add(head);
259
+ caps.push(head);
260
+ }
261
+ if (caps.length >= 3) break;
262
+ }
263
+ return caps.join(" | ") || "capabilities unknown";
264
+ }
265
+
266
+ function catalogText(tools) {
267
+ // Affordance fix: group folded tools by server and emit a capability
268
+ // summary per server (not a callable-looking name list). The model must
269
+ // expand arguments via tools_schema before calling a folded tool.
270
+ const byServer = new Map();
271
+ for (const t of tools) {
272
+ const s = serverOf(t.name);
273
+ if (!byServer.has(s)) byServer.set(s, []);
274
+ byServer.get(s).push(t);
275
+ }
276
+ const lines = [...byServer.entries()]
277
+ .sort((a, b) => b[1].length - a[1].length)
278
+ .map(([server, ts]) => `- [${server}]: ${ts.length} tools — ${summarizeCaps(ts)}`);
279
+ return [
280
+ "Additional tools exist but are not loaded for this request. " +
281
+ "They are grouped by server below. To call one: first expand its " +
282
+ "arguments with tools_schema (exact tool name), then call it by name.",
283
+ ...lines,
284
+ ].join("\n");
285
+ }
286
+
287
+ export function apply(ctx, config) {
288
+ const cfg = { ...DEFAULTS, ...(config || {}) };
289
+
290
+ // T01: normalize the v0.1.5 config surface right after the DEFAULTS merge.
291
+ // - compressLevel accepts only the four valid tiers; anything else → "off".
292
+ // - compressEnabled is the deprecated compat switch: "off" + true → "standard".
293
+ // - include patterns are trimmed/non-empty; empty list = include-all.
294
+ // - category must be a plain object; malformed values are ignored.
295
+ const VALID_LEVELS = ["off", "light", "standard", "aggressive"];
296
+ const compressLevel = VALID_LEVELS.includes(cfg.compressLevel) ? cfg.compressLevel : "off";
297
+ const effectiveLevel = compressLevel !== "off" ? compressLevel : (cfg.compressEnabled ? "standard" : "off");
298
+ const includePatterns = (cfg.include || []).map((n) => String(n).trim()).filter(Boolean);
299
+ const includeAll = includePatterns.length === 0;
300
+ const categoryCfg = (cfg.category && typeof cfg.category === "object") ? cfg.category : {};
301
+
302
+ const logger = ctx.logger("tool-folder");
303
+ if (!cfg.enabled) {
304
+ logger.info("disabled by config");
305
+ return;
306
+ }
307
+
308
+ // 语义腿(v0.3.0):远程 embedding 优先,本地 Ollama 兜底。
309
+ // 索引在**后台**构建(首次约 30-40s,之后磁盘缓存命中 <100ms),
310
+ // 任何热路径都只读"已就绪的索引",绝不阻塞 assemble 或工具调用。
311
+ configureSemantic({
312
+ ollamaBase: cfg.ollamaBase,
313
+ embedModel: cfg.embedModel,
314
+ embedTimeoutMs: cfg.embedTimeoutMs,
315
+ remoteBase: cfg.embedApiBase,
316
+ remoteModel: cfg.embedApiModel,
317
+ remoteKey: cfg.embedApiKey,
318
+ });
319
+ let semIndex = null; // 已就绪的索引 {vectors, available}
320
+ let semIndexPromise = null;
321
+ let semStamp = "";
322
+
323
+ // ---- v0.3.1 本地静态嵌入语义腿(离线、零推理、零网络)----
324
+ // 产物见 tools/build-static-embed.py;目录缺失时整条腿自动跳过,
325
+ // 行为与 v0.3.0 一致(远程 → Ollama → 纯 BM25)。
326
+ const LIB_DIR = path.dirname(fileURLToPath(import.meta.url));
327
+ const staticDir = cfg.staticEmbedDir
328
+ ? path.resolve(cfg.staticEmbedDir)
329
+ : path.join(LIB_DIR, "static-embed");
330
+ let localEmb = null; // undefined=未尝试 / false=不可用 / object=可用
331
+ let localVecs = null;
332
+ let localStamp = "";
333
+
334
+ const ensureLocalSemantic = () => {
335
+ if (!cfg.semanticEnabled) return null;
336
+ if (localEmb === null || localEmb === undefined) {
337
+ try {
338
+ localEmb = openStaticEmbed(staticDir);
339
+ } catch {
340
+ localEmb = false;
341
+ }
342
+ if (localEmb && localEmb.available) {
343
+ logger.info(
344
+ "static embedding ready: %d tokens × %d dims (%.1f MB) model=%s",
345
+ localEmb.count, localEmb.dim, localEmb.bytes / 1048576, localEmb.model || "?",
346
+ );
347
+ } else {
348
+ logger.info("static embedding absent at %s — falling back to remote/ollama", staticDir);
349
+ }
350
+ }
351
+ if (!localEmb || !localEmb.available) return null;
352
+ const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
353
+ if (localVecs && localStamp === stamp) return localVecs;
354
+ localStamp = stamp;
355
+ const m = new Map();
356
+ for (const d of semanticDocs()) {
357
+ const v = localEmb.embed(d.text);
358
+ if (v) m.set(d.id, v);
359
+ }
360
+ localVecs = m;
361
+ return localVecs;
362
+ };
363
+
364
+ // 文档正文按工具集缓存:toolBodyText 要拼描述 + 全部参数,243 个工具每轮重算
365
+ // 会给 assemble 热路径带来无谓开销。
366
+ let semDocsCache = null;
367
+ let semDocsStamp = "";
368
+ const semanticDocs = () => {
369
+ const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
370
+ if (semDocsCache && semDocsStamp === stamp) return semDocsCache;
371
+ semDocsStamp = stamp;
372
+ semDocsCache = allTools.map((t) => ({ id: t.name, text: toolBodyText(t) }));
373
+ return semDocsCache;
374
+ };
375
+
376
+ /** 触发后台构建(幂等、非阻塞)。工具集变化时按 stamp 重建。 */
377
+ const ensureSemanticIndex = () => {
378
+ if (!cfg.semanticEnabled || allTools.length === 0) return;
379
+ const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
380
+ if (semIndexPromise && stamp === semStamp) return;
381
+ semStamp = stamp;
382
+ semIndexPromise = (async () => {
383
+ const t0 = Date.now();
384
+ const docs = semanticDocs();
385
+ // dump 放在构建之前:语料采集不该依赖 embedding 是否成功
386
+ const dumpPath = cfg.debugDump || process.env.DSH_TOOL_FOLDER_DUMP || "";
387
+ if (dumpPath) {
388
+ try {
389
+ fs.mkdirSync(path.dirname(dumpPath), { recursive: true });
390
+ fs.writeFileSync(
391
+ dumpPath,
392
+ JSON.stringify({ ts: Date.now(), count: allTools.length, tools: allTools }, null, 2),
393
+ "utf8",
394
+ );
395
+ logger.info("debug dump written: %s", dumpPath);
396
+ } catch (e) {
397
+ logger.warn("debug dump failed (%s)", e?.message);
398
+ }
399
+ }
400
+ let built;
401
+ try {
402
+ built = await buildSemanticIndex(docs, cfg.embedTimeoutMs, {
403
+ onProgress: (done, total) => {
404
+ if (done === total || done % 40 === 0) {
405
+ logger.info("semantic index progress %d/%d", done, total);
406
+ }
407
+ },
408
+ });
409
+ } catch (e) {
410
+ logger.warn("semantic index build failed (%s) — BM25 only", e?.message);
411
+ return null;
412
+ }
413
+ const st = remoteEmbedState();
414
+ logger.info(
415
+ "semantic index ready: %d/%d vectors (embedded=%d reused=%d) in %dms | provider=%s",
416
+ built.vectors.size, docs.length, built.embedded, built.reused, Date.now() - t0,
417
+ st.available ? "remote" : "ollama/offline",
418
+ );
419
+ if (!built.available && !built.vectors.size) {
420
+ logger.warn("semantic leg unavailable (no vectors) — tools_search degrades to BM25");
421
+ }
422
+
423
+ semIndex = built;
424
+ return built;
425
+ })();
426
+ // 后台跑,别让未处理的 rejection 冒泡
427
+ semIndexPromise.catch?.(() => {});
428
+ };
429
+
430
+ // 查询向量后台预热(节流 15s):让 semanticDynamic 在下一轮就能用缓存
431
+ let lastPrefetch = 0;
432
+ const prefetchQuery = (query) => {
433
+ if (!cfg.semanticEnabled || !cfg.semanticDynamic || !query) return;
434
+ if (getCachedQueryVector(query)) return;
435
+ const now = Date.now();
436
+ if (now - lastPrefetch < 15_000) return;
437
+ lastPrefetch = now;
438
+ embedQueryCached(query, cfg.embedTimeoutMs).catch(() => {});
439
+ };
440
+
441
+ // 首次 assemble 之后自动预热语义索引(allTools 只在 assemble 里才有值,
442
+ // 启动瞬间是空的)。每 15s 探一次,最多 5 分钟;unref 保证不阻止进程退出。
443
+ // 这样第一条 tools_search 之前索引通常已就绪,且不占启动时间。
444
+ let warmTicks = 0;
445
+ const warmTimer = setInterval(() => {
446
+ warmTicks += 1;
447
+ if (semIndex || semIndexPromise || warmTicks > 20) {
448
+ clearInterval(warmTimer);
449
+ return;
450
+ }
451
+ if (allTools.length) ensureSemanticIndex();
452
+ }, 15_000);
453
+ warmTimer.unref?.();
454
+
455
+ const fbPath = feedbackPath(cfg);
456
+ const feedback = loadFeedback(fbPath, logger);
457
+ let index = null;
458
+ let indexStamp = null;
459
+ let allTools = [];
460
+ let lastVisible = { agentId: "", names: [], ts: 0 };
461
+ const disposers = [];
462
+ const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
463
+
464
+ /* ------------------------------------------------------------------ */
465
+ /* v0.2.2: warm LRU + 配置三级持久化 */
466
+ /* ------------------------------------------------------------------ */
467
+ // warm:Map<name, {last}>,LRU 顺序由 last 单调递增序号决定。被
468
+ // tools_search 搜到 / tools_schema 展开 / 折叠后仍被调用的工具进入集合,
469
+ // 下一轮注入完整 schema。maxWarmTools=0 时整个 warm 失效(仍不 touch)。
470
+ const warm = new Map();
471
+ const warmEnabled = cfg.maxWarmTools > 0;
472
+ const touchWarmNames = (names) => {
473
+ if (!warmEnabled || !Array.isArray(names) || names.length === 0) return;
474
+ touchWarm(warm, names, Date.now(), cfg.maxWarmTools);
475
+ persistWarmState();
476
+ };
477
+
478
+ // 三级持久化:① feedbackPath 同目录(首选)→ ② ~/.dsh/state/ → ③ 进程内。
479
+ // ⚠️ 绝不写 cordis.patch.yml(宿主配置,插件只读宿主、只落自有 state 文件)。
480
+ const primaryStateFile = () => path.join(path.dirname(fbPath), "tool-folder-state.json");
481
+ const fallbackStateFile = () => {
482
+ const home = process.env.DSH_HOME || process.env.USERPROFILE || process.env.HOME || "";
483
+ return home ? `${home}/.dsh/state/tool-folder-state.json` : "tool-folder-state.json";
484
+ };
485
+ let activeStateFile = primaryStateFile();
486
+ const restoreWarm = () => {
487
+ if (!warmEnabled) return; // 关闭态绝不载入磁盘残留(Spec 2: maxWarmTools=0=彻底关闭)
488
+ let cloud = readState(activeStateFile, null);
489
+ if (!cloud) {
490
+ activeStateFile = fallbackStateFile();
491
+ cloud = readState(activeStateFile, null);
492
+ }
493
+ if (cloud && Array.isArray(cloud.warm)) {
494
+ for (const [name, meta] of cloud.warm) {
495
+ if (typeof name === "string" && meta && typeof meta.last === "number") {
496
+ warm.set(name, { last: meta.last });
497
+ }
498
+ }
499
+ evictWarm(warm, cfg.maxWarmTools); // 载入即按上限裁剪
500
+ }
501
+ };
502
+ const persistWarmState = () => {
503
+ if (!warmEnabled) return;
504
+ // merge 写:与 discoveries 共用 state 文件,整写会互抹(Standards 轴 P1)。
505
+ const payload = { ...readState(activeStateFile, {}), warm: [...warm.entries()], ts: Date.now() };
506
+ let ok = writeState(activeStateFile, payload);
507
+ if (!ok) {
508
+ // 首选目录不可写 → 降到 ~/.dsh/state/
509
+ activeStateFile = fallbackStateFile();
510
+ writeState(activeStateFile, payload);
511
+ }
512
+ };
513
+ restoreWarm();
514
+
515
+ // P0-④: 发现率统计(独立于 warm 门控——maxWarmTools=0 时统计照常工作)。
516
+ // 与 warm 共用同一个 state 文件;落盘节流 30s(工具输出非注入面,频率低)。
517
+ let discoveries = new Map();
518
+ let lastPersistTs = 0;
519
+ const restoreDiscoveries = () => {
520
+ let cloud = readState(activeStateFile, null);
521
+ if (!cloud) {
522
+ activeStateFile = fallbackStateFile();
523
+ cloud = readState(activeStateFile, null);
524
+ }
525
+ discoveries = deserializeDiscoveries(cloud && Array.isArray(cloud.discoveries) ? cloud.discoveries : null);
526
+ };
527
+ const persistDiscoveries = () => {
528
+ const now = Date.now();
529
+ if (now - lastPersistTs < 30000) return; // 节流:30s 内只写一次
530
+ lastPersistTs = now;
531
+ // merge 写:与 warm 共用 state 文件,整写会互抹(Standards 轴 P1)。
532
+ const payload = { ...readState(activeStateFile, {}), discoveries: serializeDiscoveries(discoveries), ts: now };
533
+ let ok = writeState(activeStateFile, payload);
534
+ if (!ok) {
535
+ activeStateFile = fallbackStateFile();
536
+ writeState(activeStateFile, payload);
537
+ }
538
+ };
539
+ restoreDiscoveries();
540
+ if (discoveries.size > 0) {
541
+ const top = serializeDiscoveries(discoveries, 5).map((e) => `${e.name}(${e.count})`).join(", ");
542
+ logger.info("discovery stats restored: %d tools, top: %s", discoveries.size, top);
543
+ }
544
+
545
+ // P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
546
+ const searchCache = createSearchCache(200, 60000);
547
+
548
+ // serverOf is the module-level function defined above (mcp__server__tool →
549
+ // "mcp__server", openhands_* → "openhands", plain native tools → "native").
550
+ // P3-1 (2026-09-02): the previous apply-local copy (split("__").slice(0,2))
551
+ // let plain tools be their own "server", diverging from catalogText grouping
552
+ // and making sibling closure useless for native/openhands families. One
553
+ // definition, one口径.
554
+
555
+ // Pattern matching shared by deny (P0) and include (P2-1): exact name or
556
+ // "prefix*" matches any tool starting with that prefix.
557
+ function matchesAnyPattern(name, patterns) {
558
+ if (!name || !patterns || patterns.length === 0) return false;
559
+ const n = String(name);
560
+ for (const p of patterns) {
561
+ if (p.endsWith("*")) {
562
+ if (n.startsWith(p.slice(0, -1))) return true;
563
+ } else if (n === p) {
564
+ return true;
565
+ }
566
+ }
567
+ return false;
568
+ }
569
+ // deny config: exact name, or "prefix*" matches any tool starting with it.
570
+ // Deny wins over core, include and the catalog (a denied tool is not listed).
571
+ const denyPatterns = (cfg.deny || []).map((n) => String(n).trim()).filter(Boolean);
572
+ const isDenied = (n) => matchesAnyPattern(n, denyPatterns);
573
+
574
+ // Per-server top-K: rank the matched server's tools by BM25 relevance with
575
+ // the matched intent keywords bridged into the query. When there is no
576
+ // lexical signal at all, fall back to a stable name-order subset so the
577
+ // intent route still helps. Used by both aliases and category routing.
578
+ function addPerServerTopK(dyn, tools, serverPrefix, bridgedQuery, maxK) {
579
+ const serverIndices = [];
580
+ for (let i = 0; i < tools.length; i++) {
581
+ if (tools[i].name.startsWith(serverPrefix)) serverIndices.push(i);
582
+ }
583
+ if (serverIndices.length === 0) return;
584
+ const k = Math.min(maxK, serverIndices.length);
585
+ let ranked = [];
586
+ if (index) {
587
+ ranked = search(index, bridgedQuery, serverIndices.length, {
588
+ filter: (i) => tools[i].name.startsWith(serverPrefix),
589
+ });
590
+ }
591
+ if (ranked.length === 0) {
592
+ // No lexical signal at all → stable name order so the route still helps.
593
+ serverIndices.sort((a, b) => (tools[a].name < tools[b].name ? -1 : 1));
594
+ for (const i of serverIndices.slice(0, k)) dyn.add(tools[i].name);
595
+ } else {
596
+ for (const i of ranked.slice(0, k)) dyn.add(tools[i].name);
597
+ }
598
+ }
599
+
600
+ function buildDynamicSet(tools, query) {
601
+ const dyn = new Set();
602
+ if (!query) return dyn;
603
+ const q = query.toLowerCase();
604
+ // P1-1: category wins over aliases. When a category key matches, only the
605
+ // category's servers are routed per-server top-K; the aliases branch is
606
+ // skipped. The generic BM25 leg is still merged as a union afterwards.
607
+ const cat = matchCategory(q, categoryCfg);
608
+ if (cat.matched) {
609
+ for (const m of cat.matches) {
610
+ addPerServerTopK(dyn, tools, m.server, `${query} ${m.keywords.join(" ")}`, 3);
611
+ }
612
+ } else {
613
+ // aliases fallback — narrowed to a single representative tool per
614
+ // matching server (topK=1). A whole-server injection cancels the fold
615
+ // budget; the rest of the server stays discoverable via tools_search
616
+ // and the grouped catalog.
617
+ for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
618
+ const matched = keywords.filter((k) => q.includes(String(k).toLowerCase()));
619
+ if (matched.length === 0) continue;
620
+ addPerServerTopK(dyn, tools, prefix, `${query} ${matched.join(" ")}`, 1);
621
+ }
622
+ }
623
+ // Generic BM25 last as a union (result set unchanged vs. previous order).
624
+ if (cfg.topK > 0 && index) {
625
+ for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
626
+ }
627
+ // v0.3.0: 语义腿参与自动注入。**只用已缓存的查询向量**——热路径绝不等网络,
628
+ // 所以这里不会拖慢 assemble,也不会撞 maxFoldMs。查询向量由 prefetchQuery
629
+ // 在后台预热(同一条消息的第二轮及以后通常已命中缓存)。
630
+ if (cfg.semanticDynamic && cfg.topK > 0 && semIndex?.available) {
631
+ try {
632
+ const hits = searchSemanticCached(semIndex.vectors, semanticDocs(), query);
633
+ for (const h of hits.slice(0, cfg.topK)) dyn.add(h.id);
634
+ } catch {
635
+ /* 语义腿失败绝不影响折叠 */
636
+ }
637
+ }
638
+ return dyn;
639
+ }
640
+
641
+ // L4: `tools_search` — model-driven discovery of folded tools.
642
+ if (cfg.toolSearchEnabled) {
643
+ const outputSchema = {
644
+ type: "object",
645
+ additionalProperties: false,
646
+ properties: {
647
+ query: { type: "string", required: true },
648
+ results: {
649
+ type: "array",
650
+ required: true,
651
+ items: {
652
+ type: "object",
653
+ additionalProperties: false,
654
+ properties: {
655
+ name: { type: "string", required: true },
656
+ description: { type: "string", required: true },
657
+ server: { type: "string", required: true },
658
+ },
659
+ },
660
+ },
661
+ total: { type: "integer", required: true },
662
+ fallback: { type: "boolean", description: "true = 零匹配,结果回退为按名字典序的轻目录" },
663
+ },
664
+ };
665
+ const toolsSearch = defineTool({
666
+ name: "tools_search",
667
+ description:
668
+ "Search the full tool catalog for a task. Use when the tools loaded " +
669
+ "for this request may not cover what you need — e.g. a server-specific " +
670
+ "capability — and you want to know if a matching tool exists. " +
671
+ "Returns matching tool names and descriptions; you may then call any " +
672
+ "returned tool directly by name.",
673
+ parameters: {
674
+ query: {
675
+ type: "string",
676
+ required: true,
677
+ description: "Search keywords, e.g. 'openhands session' or 'context7 documentation'.",
678
+ },
679
+ limit: { type: "number", description: "Max results. Default 8." },
680
+ },
681
+ output: { schema: outputSchema, render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }] },
682
+ isConcurrencySafe: () => true,
683
+ async execute(args) {
684
+ // 整体 fail-open 兜底(Spec 轴 P2):任何未来 helper 意外抛错 → 空结果
685
+ // 而非硬失败,保持"折叠绝不误伤能力"的底线。
686
+ try {
687
+ const q = String(args.query || "");
688
+ const lim = args.limit ?? 8;
689
+ if (!index) return { query: q, results: [], total: 0 };
690
+ // P0-②: 同 query 内容指纹的结果缓存——重复搜索返回同一结果集(漂移的
691
+ // 最小修)。进程级共享(非单会话隔离);空 query("*" 语义)不走缓存。
692
+ const cacheKey = `${lim}|${q}|${indexStamp || ""}`;
693
+ if (q) {
694
+ const cached = searchCache.get(cacheKey);
695
+ if (cached) return cached;
696
+ }
697
+ // P0-③: 中文意图→英文同义词扩展(只加词不改原词;精确名匹配用原 q)。
698
+ // v0.3.1:在 ux 的单向 CN→EN 字典之上,再叠一层 expand.js 的意图词表
699
+ // (中英双向 + 反向别名)。实测 recall@3 0.725 → 1.000、MRR 0.672 → 0.888
700
+ // (eval/run.mjs,36 工具 / 40 查询)。零命中查询从 5 条降到 0 条。
701
+ // 两个形态同时保留,别混用:
702
+ // eqStr — 拼好的字符串,喂语义腿(它只接受原始文本,内部自己 embed)
703
+ // eq — token 数组,喂 BM25(避免二次分词把中文 bigram 拆散)
704
+ let eqStr = q;
705
+ let eq = q;
706
+ if (cfg.queryExpandEnabled !== false) {
707
+ const base = expandQuery(q); // 旧:返回字符串
708
+ const extra = expandQueryTokens(q, { aliases: cfg.aliases || {} }); // 新:token 数组
709
+ if (extra.length) {
710
+ eqStr = `${base} ${extra.join(" ")}`;
711
+ eq = tokenize(base).concat(extra);
712
+ } else {
713
+ eqStr = base;
714
+ eq = base;
715
+ }
716
+ }
717
+ // v0.2.1: exact-name > name-substring > BM25 priority (deterministic
718
+ // fallback chain, borrowed from Hermes-style tool search).
719
+ const bm25Hits = prioritizeExact(
720
+ allTools,
721
+ q,
722
+ search(index, eq, lim * 2).map((i) => ({ id: allTools[i].name })),
723
+ );
724
+ const byName = new Map(allTools.map((t) => [t.name, t]));
725
+ let fused = bm25Hits;
726
+ // 语义腿(v0.3.0):远程优先,加权 RRF。索引在后台构建——这里绝不等待
727
+ // 冷启动(否则一次搜索能卡 30s+),尚未就绪就先返回 BM25 结果,
728
+ // 同时由 ensureSemanticIndex 继续在后台补,下一次搜索自然用上。
729
+ if (cfg.semanticEnabled !== false) {
730
+ try {
731
+ // v0.3.1:本地静态嵌入优先。离线、零推理、毫秒级,且不依赖订阅。
732
+ // 只有本地缺失(没构建过 static-embed)才退回远程/Ollama。
733
+ const ls = ensureLocalSemantic();
734
+ // v0.3.2:查询向量为空(整句 token 全在词表外,或全是标点)时不能进融合。
735
+ // 否则所有文档余弦都是 0,排序退化为 Map 插入顺序,会给 topK 塞进
736
+ // 一批与查询无关的工具,比不加语义腿更糟。
737
+ const qv0 = ls && ls.size ? localEmb.embed(eqStr) : null;
738
+ const usedLocal = !!(qv0);
739
+ if (usedLocal) {
740
+ const qv = qv0;
741
+ const localHits = [];
742
+ for (const [id, v] of ls) localHits.push({ id, score: cosine(qv, v) });
743
+ localHits.sort((a, b) => b.score - a.score);
744
+ // v0.3.2:本地腿单列权重。实测(eval/sweep-static.mjs,40 标注 + 29 留出)
745
+ // 静态腿最佳配比是 bm25:static = 1:0.15 —— 静态腿的价值在于「补词法
746
+ // 查不到的坑」,而不在于压过词法本身。这一点和远程 bge-m3 腿不同:
747
+ // 后者质量更高,用 semanticWeight(3) 是对的;本地静态腿按 3 会被噪声主导
748
+ // (实测 w=2 时留出集 recall@3 从 0.621 掉到 0.517)。
749
+ // 因此本地腿用 staticWeight,且 bmWeight 固定 1:不叠加 adaptiveBm 的降权,
750
+ // 否则两处调整相乘(0.25×3=0.75 等效)偏离压测配置。
751
+ fused = rrfFuse(bm25Hits, localHits.slice(0, lim * 2), lim, {
752
+ semWeight: cfg.staticWeight,
753
+ bmWeight: 1,
754
+ });
755
+ }
756
+ if (!usedLocal) ensureSemanticIndex();
757
+ if (!usedLocal && semIndex?.available) {
758
+ const semHits = await searchSemantic(semIndex.vectors, semanticDocs(), eqStr);
759
+ // 自适应 BM25 权重:纯中文查询下 BM25 只有 CJK bigram 噪声,
760
+ // 降权 0.25;有真实英文锚点(工具名/server/关键词)时保持 1。
761
+ const topTool = bm25Hits[0] ? byName.get(bm25Hits[0].id) : null;
762
+ const bw =
763
+ cfg.adaptiveBm === false
764
+ ? cfg.bmWeight
765
+ : adaptiveBmWeight(q, bm25Hits[0]?.id, topTool?.description);
766
+ fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim, {
767
+ semWeight: cfg.semanticWeight,
768
+ bmWeight: bw,
769
+ });
770
+ }
771
+ } catch (e) {
772
+ logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
773
+ }
774
+ }
775
+
776
+ // v0.3.0 正文精排(借鉴 SkillRouter 两阶段检索):在已召回候选集内用
777
+ // 完整描述 + 参数 schema 重排,**不扩大候选**(技能侧实测扩大候选会
778
+ // 让泛化词把无关项顶上来)。候选之外的 BM25/语义命中保持原序接在后面。
779
+ if (cfg.bodyRerank && fused.length > 1) {
780
+ try {
781
+ const topN = Math.min(cfg.bodyRerankTopN, fused.length);
782
+ const head = fused.slice(0, topN);
783
+ const tail = fused.slice(topN);
784
+ const reranked = rerankByBody(q, head, byName, { weight: cfg.bodyRerankWeight });
785
+ fused = [...reranked.map((r) => ({ id: r.id })), ...tail];
786
+ } catch (e) {
787
+ logger.warn("body rerank failed (%s) — keep fused order", e?.message);
788
+ }
789
+ }
790
+
791
+ // 精确名/名字串命中永远钉在最前(正文重排也不许把它挤下去)。
792
+ const exact = bm25Hits.filter((h) => {
793
+ const name = String(h.id || "").toLowerCase();
794
+ const needle = q.trim().toLowerCase();
795
+ return needle && (name === needle || name.endsWith(needle) || name.includes(needle));
796
+ });
797
+ if (exact.length) {
798
+ const pinned = new Set(exact.map((h) => h.id));
799
+ fused = [...exact, ...fused.filter((h) => !pinned.has(h.id))];
800
+ }
801
+
802
+ let results = fused.map((h) => {
803
+ const t = byName.get(h.id) || {};
804
+ return {
805
+ name: h.id,
806
+ description: (t.description || "").slice(0, 160),
807
+ server: serverOf(h.id),
808
+ };
809
+ });
810
+ // P0-①: 零匹配回退轻目录(name+80字描述,名字典序)——根治「搜词不对
811
+ // 就以为没有该能力」。fallbackCatalogSize=0 关闭(返回空=旧行为)。
812
+ // total 在回退时=全量目录大小(而非列出条数),避免模型误以为目录总容量=limit。
813
+ let fallback = false;
814
+ if (results.length === 0 && cfg.fallbackCatalogSize > 0) {
815
+ results = fallbackCatalog(allTools, cfg.fallbackCatalogSize, serverOf);
816
+ fallback = true;
817
+ }
818
+ // v0.2.2: 被搜到的工具名进入 warm 集合,下一轮注入完整 schema。
819
+ touchWarmNames(results.map((r) => r.name));
820
+ // P0-④: 发现率统计(节流落盘,低频工具名单反哺描述改进)。
821
+ recordDiscoveries(discoveries, results.map((r) => r.name));
822
+ persistDiscoveries();
823
+ const out = {
824
+ query: q,
825
+ results,
826
+ total: fallback ? allTools.length : results.length,
827
+ ...(fallback ? { fallback: true } : {}),
828
+ };
829
+ if (q) searchCache.set(cacheKey, out);
830
+ return out;
831
+ } catch (e) {
832
+ logger.warn("tools_search failed (%s) — empty results", e?.message);
833
+ return { query: String(args.query || ""), results: [], total: 0 };
834
+ }
835
+ },
836
+ });
837
+ try {
838
+ ctx.tools.register(toolsSearch);
839
+ logger.info("tools_search registered");
840
+ } catch (e) {
841
+ logger.warn("tools_search register failed: %s", e?.message);
842
+ }
843
+ }
844
+
845
+ // L4: `tools_schema` — model-driven full-schema discovery for one tool.
846
+ // Complementary to tools_search: search narrows candidates, this returns the
847
+ // exact parameter schema + full description of one named tool.
848
+ // RED LINE (C3): output MUST be `{ schema: { type: "json" } }` — the return
849
+ // value is an arbitrary tool schema, and a strict object schema with
850
+ // additionalProperties:false would fail the host's "declared property"
851
+ // validation (ToolOutputError). The `type:"json"` annotation schema only
852
+ // checks lossless JSON (C4).
853
+ if (cfg.schemaToolEnabled) {
854
+ const schemaTool = defineTool({
855
+ name: "tools_schema",
856
+ description:
857
+ "Get the complete parameter schema of one or more tools by name. Use after " +
858
+ "tools_search narrows candidates, or before calling a tool whose exact " +
859
+ "arguments you need. Returns the full JSON schema of parameters plus the " +
860
+ "full description; sibling tools from the same server are listed alongside " +
861
+ "(lightweight) so you can batch-expand what you need.",
862
+ parameters: {
863
+ name: {
864
+ type: "string",
865
+ description: "Exact tool name, e.g. 'mcp__viking__remember' (either name or names).",
866
+ },
867
+ names: {
868
+ type: "array",
869
+ items: { type: "string" },
870
+ description: "Batch: exact tool names to expand in one call (either name or names).",
871
+ },
872
+ closure: {
873
+ type: "boolean",
874
+ description: "Include lightweight sibling tools from the same server (default true).",
875
+ },
876
+ },
877
+ output: {
878
+ schema: { type: "json" },
879
+ render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }],
880
+ },
881
+ isConcurrencySafe: () => true,
882
+ async execute(args) {
883
+ // P1: 批量展开(names 数组)+ 同 server 兄弟闭包(closureSize 条轻目录)。
884
+ // 输出契约全部在 buildSchemaResponse(纯函数,lib/ux.js 可单测)。
885
+ const res = buildSchemaResponse(allTools, args, { closureSize: cfg.closureSize, serverOf });
886
+ // v0.2.2: 展开过 schema 的工具名进入 warm 集合,下一轮保持注入。
887
+ touchWarmNames(
888
+ Array.isArray(res.results) ? res.results.map((r) => r.name) : res.found && res.name ? [res.name] : [],
889
+ );
890
+ return res;
891
+ },
892
+ });
893
+ try {
894
+ ctx.tools.register(schemaTool);
895
+ logger.info("tools_schema registered");
896
+ } catch (e) {
897
+ logger.warn("tools_schema register failed: %s", e?.message);
898
+ }
899
+ }
900
+
901
+ disposers.push(
902
+ ctx.on("system-prompt/assemble", async (assembly, context, next) => {
903
+ const t0 = Date.now();
904
+ try {
905
+ const assembled = await next();
906
+ const assembledTools = assembled.tools || [];
907
+ if (!Array.isArray(assembledTools)) return assembled;
908
+ // P2-1 include whitelist + deny config remove tools from the injected
909
+ // surface BEFORE folding. Semantics: include non-empty → whitelist mode,
910
+ // only matching tools enter the pool (injection surface + catalog +
911
+ // tools_search/tools_schema search scope); execution is NOT gated here
912
+ // (C5 — pre-execute only checks deny + firewall). Deny still wins.
913
+ // Meta tools are exempt from include so a whitelist can never hide the
914
+ // discovery tools themselves (that would break the always-visible loop).
915
+ const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
916
+ // v0.3.0: 按名字去重。宿主的组装结果在极端情况下可能重复(同一工具被两个
917
+ // 插件注册),重复项会让 BM25/RRF 把同一 id 的分数算两次——实测能把正确项
918
+ // 从第一挤到第三以外。
919
+ const tools = [];
920
+ const seenToolNames = new Set();
921
+ for (const t of assembledTools) {
922
+ if (isDenied(t?.name)) continue;
923
+ if (!(isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns))) continue;
924
+ if (seenToolNames.has(t.name)) continue;
925
+ seenToolNames.add(t.name);
926
+ tools.push(t);
927
+ }
928
+ if (tools.length === 0) return { ...assembled, tools };
929
+
930
+ const agentId = context?.agent?.id || "default";
931
+ const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
932
+ // v0.2.1: hot = sliding-window decay (hotWindowDays). Tools not called
933
+ // inside the window lose their historical heat — the old code promoted
934
+ // forever, which slowly nullified the fold.
935
+ const hotNames = new Set(
936
+ Object.keys(feedback.calls || {}).filter((n) =>
937
+ isHotTool(feedback.calls, n, cfg.hotThreshold, cfg.hotWindowDays * 86400000)),
938
+ );
939
+
940
+ const stamp = tools.map((t) => t.name).join("\u0001");
941
+ if (indexStamp !== stamp) {
942
+ // V4 document expansion (measured 0.1.7→0.1.8): the tool corpus is
943
+ // pure-English, so Chinese queries always get df=0 and BM25 scores
944
+ // nothing (measured recall 0/26). Inject per-tool aliases from
945
+ // cfg.aliases (prefix-matched) into the index text — CN recall
946
+ // 0%→88%, EN +15pp, both from the same expansion.
947
+ index = buildIndex(
948
+ tools.map((t) => {
949
+ const aliasWords = [];
950
+ for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
951
+ if (t.name.startsWith(prefix)) aliasWords.push(...keywords);
952
+ }
953
+ return {
954
+ id: t.name,
955
+ text: `${t.name} ${t.description || ""} ${aliasWords.join(" ")}`,
956
+ };
957
+ }),
958
+ );
959
+ indexStamp = stamp;
960
+ allTools = tools;
961
+ logger.info("index rebuilt: %d tools (doc-expansion enabled)", tools.length);
962
+
963
+ }
964
+
965
+ const query = extractQuery(context);
966
+ // v0.3.0:语义索引在后台构建(首次冷启动),查询向量也后台预热。
967
+ // 两处都是 fire-and-forget:assemble 是热路径,绝不因为 embedding 变慢。
968
+ ensureSemanticIndex();
969
+ prefetchQuery(query);
970
+ const dynNames = buildDynamicSet(tools, query);
971
+
972
+ // v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
973
+ const warmNamesSet = new Set(
974
+ [...warm.keys()].filter((n) => tools.some((t) => t.name === n)),
975
+ );
976
+
977
+ // L5: stable ordering — core by config order, hot by name, dyn by name.
978
+ // A deterministic byte order keeps the prompt prefix cacheable.
979
+ const coreTools = tools.filter((t) => coreNames.has(t.name));
980
+ const hotTools = tools
981
+ .filter((t) => !coreNames.has(t.name) && hotNames.has(t.name))
982
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
983
+ const dynTools = tools
984
+ .filter(
985
+ (t) =>
986
+ !coreNames.has(t.name) &&
987
+ !hotNames.has(t.name) &&
988
+ !warmNamesSet.has(t.name) &&
989
+ dynNames.has(t.name),
990
+ )
991
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
992
+
993
+ // v0.2.2: warm = 上一轮被 tools_search/schema 发现或折叠后被调用的工具。
994
+ // 属主动晋升(接近 hot),完整 schema 注入,不作为 budget 可降级段。
995
+ const warmTools = tools
996
+ .filter(
997
+ (t) =>
998
+ !coreNames.has(t.name) &&
999
+ !hotNames.has(t.name) &&
1000
+ warmNamesSet.has(t.name),
1001
+ )
1002
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
1003
+
1004
+ // L1: schema compression + description normalization on the dynamic
1005
+ // segment only. core/hot/warm never pass through this pipe (P0 red line).
1006
+ const trimmed =
1007
+ effectiveLevel !== "off" || cfg.normalizeDescriptions
1008
+ ? dynTools.map((t) => {
1009
+ let out = t;
1010
+ if (cfg.normalizeDescriptions && typeof t.description === "string") {
1011
+ out = { ...out, description: normalizeDescription(t.description) };
1012
+ }
1013
+ return effectiveLevel !== "off"
1014
+ ? compressTool(out, { level: effectiveLevel })
1015
+ : out;
1016
+ })
1017
+ : dynTools;
1018
+
1019
+ // v0.2.2 分级披露预算:动态段(trimmed)是唯一可降级段。core/hot/warm
1020
+ // 作为 protected 恒层级 1。tierForBudget 返回每个可见名字的降级层级;
1021
+ // 层级 2/3 组装压缩形态,层级 4 不再注入(由 catalog 兜底发现)。
1022
+ let visible = [...coreTools, ...hotTools, ...warmTools, ...trimmed];
1023
+ if (cfg.disclosureBudget > 0) {
1024
+ const protectedNames = new Set([
1025
+ ...coreNames,
1026
+ ...hotNames,
1027
+ ...warmNamesSet,
1028
+ // meta 工具永远完整可见
1029
+ "tools_search",
1030
+ "tools_schema",
1031
+ ]);
1032
+ const tiers = tierForBudget(visible, cfg.disclosureBudget, protectedNames);
1033
+ // Effective budget = min(disclosureBudget, context estimate). We do not
1034
+ // separately measure context here, so the hard disclosureBudget stands.
1035
+ // Rebuild visible honoring per-tool tiers (they are already ordered).
1036
+ visible = visible.map((t) => {
1037
+ const tier = tiers.byName[t.name] || 1;
1038
+ if (tier === 1) return t; // 完整 schema
1039
+ if (tier === 2) {
1040
+ // name + description(描述压缩至 100 字符)
1041
+ return {
1042
+ name: t.name,
1043
+ description: String(t.description || "").slice(0, 100),
1044
+ };
1045
+ }
1046
+ if (tier === 3) {
1047
+ // 仅 name(一行一个)
1048
+ return { name: t.name };
1049
+ }
1050
+ return null; // 层级 4:折叠进 catalog,不注入
1051
+ }).filter(Boolean);
1052
+ }
1053
+
1054
+ // The meta-tools must always stay visible or the model can never ask
1055
+ // for folded tools (they would be hidden by their own fold).
1056
+ const META_TOOLS = [
1057
+ { name: "tools_search", enabled: cfg.toolSearchEnabled },
1058
+ { name: "tools_schema", enabled: cfg.schemaToolEnabled },
1059
+ ];
1060
+ for (const m of META_TOOLS) {
1061
+ if (m.enabled && !visible.some((t) => t.name === m.name)) {
1062
+ const meta = tools.find((t) => t.name === m.name);
1063
+ if (meta) visible.push(meta);
1064
+ }
1065
+ }
1066
+
1067
+ // v0.2.2 fail-open 硬保证(借鉴 fan56/dsh-mcp-adapter):启用的发现
1068
+ // 元工具仍缺失(注册失败/名冲突/部分启动)→ 折叠后工具不可发现,
1069
+ // 比不折叠更糟——显式回退官方全量工具列表,绝不静默吞掉发现路径。
1070
+ const missingMeta = missingMetaTools(
1071
+ new Set(visible.map((t) => t.name)),
1072
+ { tools_search: cfg.toolSearchEnabled, tools_schema: cfg.schemaToolEnabled },
1073
+ );
1074
+ if (missingMeta.length > 0) {
1075
+ logger.warn(
1076
+ "fail-open: meta tool(s) %s missing after fold — falling back to full tool list",
1077
+ missingMeta.join(","),
1078
+ );
1079
+ return next();
1080
+ }
1081
+
1082
+ const visibleNames = visible.map((t) => t.name);
1083
+ lastVisible = { agentId, names: visibleNames, ts: Date.now() };
1084
+
1085
+ let sections = assembled.sections;
1086
+ if (cfg.catalogEnabled) {
1087
+ const folded = tools.filter(
1088
+ (t) =>
1089
+ !isMetaTool(t.name) &&
1090
+ !coreNames.has(t.name) &&
1091
+ !hotNames.has(t.name) &&
1092
+ !warmNamesSet.has(t.name) &&
1093
+ !dynNames.has(t.name),
1094
+ );
1095
+ if (folded.length > 0) {
1096
+ sections = [...(assembled.sections || []), {
1097
+ name: "tool-folder-catalog",
1098
+ text: catalogText(folded),
1099
+ }];
1100
+ }
1101
+ }
1102
+
1103
+ logger.info(
1104
+ "inject=%d/%d agent=%s dyn=%d hot=%d q=%s",
1105
+ visible.length, tools.length, agentId, dynNames.size, hotNames.size,
1106
+ query ? query.slice(0, 40) : "(none)",
1107
+ );
1108
+
1109
+ return { ...assembled, tools: visible, sections };
1110
+ } catch (e) {
1111
+ logger.warn("fold failed (%s) — falling back to full tool list", e?.message);
1112
+ return next();
1113
+ } finally {
1114
+ const dt = Date.now() - t0;
1115
+ if (dt > cfg.maxFoldMs) logger.warn("assemble fold took %dms (> %dms)", dt, cfg.maxFoldMs);
1116
+ }
1117
+ }),
1118
+ );
1119
+
1120
+ // L3 + L4: heat feedback, coverage recording, and folded-call detection.
1121
+ // Bound to `tools/post-execute` — payload = (exec, result), exec.name /
1122
+ // exec.arguments are available. (`agent/pre-step` was the original hook but
1123
+ // its payload is { messages, ...position, signal } — no toolCalls — so the
1124
+ // feedback loop had no data. Verified against dsh-agent-loop preStep().)
1125
+ // This listener is deliberately side-effect only: it always returns next()
1126
+ // and never throws, so a feedback bug can never turn a tool result into
1127
+ // an isError (postExecute wraps a throwing listener as failure).
1128
+ disposers.push(
1129
+ ctx.on("tools/post-execute", (exec, _result, next) => {
1130
+ try {
1131
+ const name = exec?.name;
1132
+ if (!name) return next();
1133
+
1134
+ // P2-2 toonify (opt-in, default off): compact long JSON text blocks in
1135
+ // the result (drop empty fields + truncate long strings). Only fires
1136
+ // when the text block is >2000 chars AND parses as JSON; any failure
1137
+ // leaves the original block untouched — a compaction bug can never
1138
+ // corrupt a tool result.
1139
+ if (cfg.toonifyResults && _result && Array.isArray(_result.content)) {
1140
+ _result.content = _result.content.map((block) => {
1141
+ if (
1142
+ block?.type !== "text" ||
1143
+ typeof block.text !== "string" ||
1144
+ block.text.length <= 2000
1145
+ ) {
1146
+ return block;
1147
+ }
1148
+ try {
1149
+ const v = JSON.parse(block.text);
1150
+ const compact = JSON.stringify(toonifyValue(v), null, 2);
1151
+ return compact.length < block.text.length ? { ...block, text: compact } : block;
1152
+ } catch {
1153
+ return block;
1154
+ }
1155
+ });
1156
+ }
1157
+
1158
+ // Heat counting (L4 feedback). v0.2.1: upgrade to { n, ts } so the
1159
+ // hot gate can decay stale heat; legacy number entries are kept
1160
+ // readable by isHotTool (ts=0 → outside any positive window).
1161
+ const prev = feedback.calls[name];
1162
+ feedback.calls[name] = {
1163
+ n: (typeof prev === "number" ? prev : prev?.n || 0) + 1,
1164
+ ts: Date.now(),
1165
+ };
1166
+
1167
+ // Firewall: record the call sequence and flag exfil chains.
1168
+ if (cfg.firewallEnabled !== false) {
1169
+ chain.record(name, execArgumentsText(exec?.arguments));
1170
+ const exfil = chain.checkChain();
1171
+ if (exfil) {
1172
+ logger.warn(
1173
+ "[firewall] %s: %s",
1174
+ exfil.label, exfil.chain.join(" → "),
1175
+ );
1176
+ feedback.firewall = feedback.firewall || [];
1177
+ feedback.firewall.push({ ts: Date.now(), ...exfil });
1178
+ if (feedback.firewall.length > 20) {
1179
+ feedback.firewall.splice(0, feedback.firewall.length - 20);
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ // Folded-call detection: did the model call something we hid?
1185
+ const cov = selectionCoverage(lastVisible.names, [name]);
1186
+ if (cov.uncovered.length > 0) {
1187
+ logger.warn(
1188
+ "folded tools called (coverage=%.2f): %s — promoting via heat",
1189
+ cov.coverage, cov.uncovered.join(", "),
1190
+ );
1191
+ // v0.2.2: 折叠后仍被调用的工具名也进 warm,下一轮避免再折叠。
1192
+ touchWarmNames(cov.uncovered);
1193
+ }
1194
+
1195
+ // L3: record the last 50 sessions for offline evaluation.
1196
+ feedback.sessions.push({
1197
+ ts: Date.now(),
1198
+ agent: lastVisible.agentId,
1199
+ visible: lastVisible.names,
1200
+ calls: [name],
1201
+ coverage: cov.coverage,
1202
+ uncovered: cov.uncovered,
1203
+ });
1204
+ if (feedback.sessions.length > 50) feedback.sessions.splice(0, feedback.sessions.length - 50);
1205
+
1206
+ persistFeedback(fbPath, feedback, logger);
1207
+ } catch (e) {
1208
+ logger.warn("post-execute hook error (%s) — best-effort", e?.message);
1209
+ }
1210
+ return next();
1211
+ }),
1212
+ );
1213
+
1214
+ // Firewall hard-block + deny config: refuse execution at tools/pre-execute.
1215
+ // Host contract (dsh-tools prepareExecution): the waterfall's gate is the
1216
+ // last value; return { kind: "deny", reason } to refuse, or next() to allow.
1217
+ // exec carries { callId, name, arguments, agent, signal } — no .deny().
1218
+ disposers.push(
1219
+ ctx.on("tools/pre-execute", (exec, next) => {
1220
+ try {
1221
+ const name = exec?.name;
1222
+
1223
+ // deny config: refuse execution regardless of firewallEnabled.
1224
+ if (isDenied(name)) {
1225
+ logger.warn("[deny] tool disabled by config: %s", name);
1226
+ return { kind: "deny", reason: `[tool-folder] 工具 ${name} 已被禁用(deny 配置)。` };
1227
+ }
1228
+
1229
+ if (cfg.firewallEnabled !== false) {
1230
+ // 2026-08-30 修复:高危硬拦只对「执行类工具」生效。
1231
+ // 原实现对所有工具的 arguments 跑高危正则,导致内容类工具
1232
+ // (write/edit/read 等参数是文件内容/文本)只要含危险命令的
1233
+ // 字面量(如讨论、测试、文档中的示例)就被误拦。
1234
+ // 执行类工具的参数才是真实要执行的命令,需要硬拦。
1235
+ if (isExecTool(name)) {
1236
+ const argvText = execArgumentsText(exec?.arguments);
1237
+ const v = verdict(argvText);
1238
+ if (v?.blocked) {
1239
+ logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
1240
+ return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
1241
+ }
1242
+ }
1243
+ }
1244
+ } catch (e) {
1245
+ logger.warn("pre-execute hook error (%s) — best-effort", e?.message);
1246
+ }
1247
+ return next();
1248
+ }),
1249
+ );
1250
+
1251
+ // Cleanup on plugin unload: no leaked listeners.
1252
+ return () => {
1253
+ for (const d of disposers) {
1254
+ try {
1255
+ d();
1256
+ } catch {
1257
+ /* already disposed */
1258
+ }
1259
+ }
1260
+ };
1261
+ }