dsh-tool-folder 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/lib/bm25.js +127 -112
- package/lib/chainguard.js +40 -0
- package/lib/embed-remote.js +193 -0
- package/lib/index.js +225 -54
- package/lib/obfuscation.js +11 -2
- package/lib/rerank.js +115 -0
- package/lib/semantic.js +274 -101
- package/lib/ux.js +29 -9
- package/package.json +56 -48
package/lib/index.js
CHANGED
|
@@ -37,10 +37,23 @@ import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, mi
|
|
|
37
37
|
import { tierForBudget } from "./budget.js";
|
|
38
38
|
import { readState, writeState } from "./storage.js";
|
|
39
39
|
import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
|
|
40
|
-
import { ChainGuard, verdict } from "./chainguard.js";
|
|
40
|
+
import { ChainGuard, verdict, isExecTool } from "./chainguard.js";
|
|
41
41
|
import { matchCategory } from "./category.js";
|
|
42
42
|
import { toonifyValue } from "./toonify.js";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
configureSemantic,
|
|
45
|
+
buildSemanticIndex,
|
|
46
|
+
searchSemantic,
|
|
47
|
+
searchSemanticCached,
|
|
48
|
+
embedQueryCached,
|
|
49
|
+
getCachedQueryVector,
|
|
50
|
+
rrfFuse,
|
|
51
|
+
adaptiveBmWeight,
|
|
52
|
+
DEFAULT_SEM_WEIGHT,
|
|
53
|
+
DEFAULT_BM_WEIGHT,
|
|
54
|
+
} from "./semantic.js";
|
|
55
|
+
import { rerankByBody, toolBodyText } from "./rerank.js";
|
|
56
|
+
import { remoteEmbedState } from "./embed-remote.js";
|
|
44
57
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
45
58
|
|
|
46
59
|
export const name = "tool-folder";
|
|
@@ -77,9 +90,21 @@ export const Config = z.object({
|
|
|
77
90
|
maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
|
|
78
91
|
feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
|
|
79
92
|
aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
|
|
80
|
-
semanticEnabled: z.boolean().default(true).description("
|
|
81
|
-
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama
|
|
82
|
-
embedModel: z.string().default("bge-m3").description("embedding
|
|
93
|
+
semanticEnabled: z.boolean().default(true).description("语义检索腿:tools_search 用 BM25+语义 加权 RRF 混合(远程 embedding 优先,失败回落本地 Ollama,再失败纯 BM25)"),
|
|
94
|
+
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(远程 embedding 不可用时的本地兜底)"),
|
|
95
|
+
embedModel: z.string().default("bge-m3").description("本地 Ollama embedding 模型(兜底腿)"),
|
|
96
|
+
embedTimeoutMs: z.number().min(100).default(12000).description("embedding 请求超时(毫秒)"),
|
|
97
|
+
embedApiBase: z.string().default("https://ark.cn-beijing.volces.com/api/plan/v3").description("远程 embedding 端点(OpenAI 兼容;默认火山方舟)"),
|
|
98
|
+
embedApiModel: z.string().default("doubao-embedding-vision").description("远程 embedding 模型"),
|
|
99
|
+
embedApiKey: z.string().default("").description("远程 embedding key;留空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY"),
|
|
100
|
+
semanticWeight: z.number().min(0).max(20).default(3).description("RRF 中语义腿相对 BM25 的权重:1=等权,3=偏语义(默认),0=纯 BM25"),
|
|
101
|
+
adaptiveBm: z.boolean().default(true).description("自适应 BM25 权重:查询里有真实英文锚点(工具名/server/关键词)时 BM25 权重 1,纯中文查询降到 bmWeight(实测 6/18→11/18)"),
|
|
102
|
+
bmWeight: z.number().min(0).max(5).default(0.25).description("纯中文查询下 BM25 的 RRF 权重(adaptiveBm=false 时固定用这个值)"),
|
|
103
|
+
bodyRerank: z.boolean().default(false).description("正文精排:在召回候选集内用完整描述+参数 schema 重排(借鉴 SkillRouter 两阶段检索)。实测本语料 Hit@1 持平、Hit@3 -1,故默认关"),
|
|
104
|
+
bodyRerankWeight: z.number().min(0).max(1).default(0.35).description("正文精排权重:final=(1-w)·元数据名次 + w·正文覆盖"),
|
|
105
|
+
bodyRerankTopN: z.number().min(1).max(50).default(12).description("参与正文重排的候选数(不扩大候选,只在召回集内重排)"),
|
|
106
|
+
semanticDynamic: z.boolean().default(true).description("语义腿参与每轮自动注入:只用已缓存的查询向量,零额外延迟(首次查询由后台预热)"),
|
|
107
|
+
debugDump: z.string().default("").description("调试:把当前工具语料(名/描述/schema)dump 到该路径,用于离线评测(空=关闭)"),
|
|
83
108
|
disclosureBudget: z.number().min(0).max(200000).default(0)
|
|
84
109
|
.description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
|
|
85
110
|
maxWarmTools: z.number().min(0).max(50).default(0)
|
|
@@ -121,9 +146,21 @@ const DEFAULTS = {
|
|
|
121
146
|
"mcp__open-design": ["设计", "design"],
|
|
122
147
|
"mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
|
|
123
148
|
},
|
|
124
|
-
semanticEnabled: true, //
|
|
149
|
+
semanticEnabled: true, // v0.3.0: 远程优先 → Ollama → BM25 的语义腿
|
|
125
150
|
ollamaBase: "http://127.0.0.1:11434",
|
|
126
151
|
embedModel: "bge-m3",
|
|
152
|
+
embedTimeoutMs: 12000, // v0.3.0: 远程 embedding 首页可能较慢,8s 偏紧
|
|
153
|
+
embedApiBase: "https://ark.cn-beijing.volces.com/api/plan/v3", // v0.3.0 远程语义腿
|
|
154
|
+
embedApiModel: "doubao-embedding-vision",
|
|
155
|
+
embedApiKey: "", // 空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY
|
|
156
|
+
semanticWeight: 3, // v0.3.0: RRF 语义权重(1=等权)
|
|
157
|
+
adaptiveBm: true, // v0.3.0: 纯中文查询自动降 BM25 权重
|
|
158
|
+
bmWeight: 0.25, // v0.3.0: 纯中文查询下的 BM25 权重(实测 1:1 融合只有 8/18)
|
|
159
|
+
bodyRerank: false, // v0.3.0: 实测 Hit@1 持平 / Hit@3 -1,默认关,可显式开
|
|
160
|
+
bodyRerankWeight: 0.35,
|
|
161
|
+
bodyRerankTopN: 12,
|
|
162
|
+
semanticDynamic: true, // v0.3.0: 语义腿参与自动注入(只用缓存查询向量)
|
|
163
|
+
debugDump: "", // v0.3.0: 工具语料 dump(离线评测用);也认 DSH_TOOL_FOLDER_DUMP 环境变量
|
|
127
164
|
disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
|
|
128
165
|
maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
|
|
129
166
|
fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
|
|
@@ -200,24 +237,6 @@ function serverOf(name) {
|
|
|
200
237
|
return "native";
|
|
201
238
|
}
|
|
202
239
|
|
|
203
|
-
/**
|
|
204
|
-
* 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
|
|
205
|
-
* 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
|
|
206
|
-
* 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
|
|
207
|
-
* 2026-08-30:修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
|
|
208
|
-
*/
|
|
209
|
-
function isExecTool(name) {
|
|
210
|
-
if (!name) return false;
|
|
211
|
-
const n = String(name).toLowerCase();
|
|
212
|
-
const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
|
|
213
|
-
if (EXEC_NAMES.includes(n)) return true;
|
|
214
|
-
if (n.includes("powershell")) return true; // mcp__windows__PowerShell 等(参数即命令)
|
|
215
|
-
if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
|
|
216
|
-
if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
|
|
217
|
-
if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
240
|
function summarizeCaps(tools) {
|
|
222
241
|
// Pull the first few distinct capability keywords from descriptions.
|
|
223
242
|
const seen = new Set();
|
|
@@ -276,21 +295,112 @@ export function apply(ctx, config) {
|
|
|
276
295
|
return;
|
|
277
296
|
}
|
|
278
297
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
configureSemantic({
|
|
298
|
+
// 语义腿(v0.3.0):远程 embedding 优先,本地 Ollama 兜底。
|
|
299
|
+
// 索引在**后台**构建(首次约 30-40s,之后磁盘缓存命中 <100ms),
|
|
300
|
+
// 任何热路径都只读"已就绪的索引",绝不阻塞 assemble 或工具调用。
|
|
301
|
+
configureSemantic({
|
|
302
|
+
ollamaBase: cfg.ollamaBase,
|
|
303
|
+
embedModel: cfg.embedModel,
|
|
304
|
+
embedTimeoutMs: cfg.embedTimeoutMs,
|
|
305
|
+
remoteBase: cfg.embedApiBase,
|
|
306
|
+
remoteModel: cfg.embedApiModel,
|
|
307
|
+
remoteKey: cfg.embedApiKey,
|
|
308
|
+
});
|
|
309
|
+
let semIndex = null; // 已就绪的索引 {vectors, available}
|
|
283
310
|
let semIndexPromise = null;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
311
|
+
let semStamp = "";
|
|
312
|
+
|
|
313
|
+
// 文档正文按工具集缓存:toolBodyText 要拼描述 + 全部参数,243 个工具每轮重算
|
|
314
|
+
// 会给 assemble 热路径带来无谓开销。
|
|
315
|
+
let semDocsCache = null;
|
|
316
|
+
let semDocsStamp = "";
|
|
317
|
+
const semanticDocs = () => {
|
|
318
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
319
|
+
if (semDocsCache && semDocsStamp === stamp) return semDocsCache;
|
|
320
|
+
semDocsStamp = stamp;
|
|
321
|
+
semDocsCache = allTools.map((t) => ({ id: t.name, text: toolBodyText(t) }));
|
|
322
|
+
return semDocsCache;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/** 触发后台构建(幂等、非阻塞)。工具集变化时按 stamp 重建。 */
|
|
326
|
+
const ensureSemanticIndex = () => {
|
|
327
|
+
if (!cfg.semanticEnabled || allTools.length === 0) return;
|
|
328
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
329
|
+
if (semIndexPromise && stamp === semStamp) return;
|
|
330
|
+
semStamp = stamp;
|
|
331
|
+
semIndexPromise = (async () => {
|
|
332
|
+
const t0 = Date.now();
|
|
333
|
+
const docs = semanticDocs();
|
|
334
|
+
// dump 放在构建之前:语料采集不该依赖 embedding 是否成功
|
|
335
|
+
const dumpPath = cfg.debugDump || process.env.DSH_TOOL_FOLDER_DUMP || "";
|
|
336
|
+
if (dumpPath) {
|
|
337
|
+
try {
|
|
338
|
+
fs.mkdirSync(path.dirname(dumpPath), { recursive: true });
|
|
339
|
+
fs.writeFileSync(
|
|
340
|
+
dumpPath,
|
|
341
|
+
JSON.stringify({ ts: Date.now(), count: allTools.length, tools: allTools }, null, 2),
|
|
342
|
+
"utf8",
|
|
343
|
+
);
|
|
344
|
+
logger.info("debug dump written: %s", dumpPath);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
logger.warn("debug dump failed (%s)", e?.message);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
let built;
|
|
350
|
+
try {
|
|
351
|
+
built = await buildSemanticIndex(docs, cfg.embedTimeoutMs, {
|
|
352
|
+
onProgress: (done, total) => {
|
|
353
|
+
if (done === total || done % 40 === 0) {
|
|
354
|
+
logger.info("semantic index progress %d/%d", done, total);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
} catch (e) {
|
|
359
|
+
logger.warn("semantic index build failed (%s) — BM25 only", e?.message);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const st = remoteEmbedState();
|
|
363
|
+
logger.info(
|
|
364
|
+
"semantic index ready: %d/%d vectors (embedded=%d reused=%d) in %dms | provider=%s",
|
|
365
|
+
built.vectors.size, docs.length, built.embedded, built.reused, Date.now() - t0,
|
|
366
|
+
st.available ? "remote" : "ollama/offline",
|
|
367
|
+
);
|
|
368
|
+
if (!built.available && !built.vectors.size) {
|
|
369
|
+
logger.warn("semantic leg unavailable (no vectors) — tools_search degrades to BM25");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
semIndex = built;
|
|
373
|
+
return built;
|
|
374
|
+
})();
|
|
375
|
+
// 后台跑,别让未处理的 rejection 冒泡
|
|
376
|
+
semIndexPromise.catch?.(() => {});
|
|
292
377
|
};
|
|
293
378
|
|
|
379
|
+
// 查询向量后台预热(节流 15s):让 semanticDynamic 在下一轮就能用缓存
|
|
380
|
+
let lastPrefetch = 0;
|
|
381
|
+
const prefetchQuery = (query) => {
|
|
382
|
+
if (!cfg.semanticEnabled || !cfg.semanticDynamic || !query) return;
|
|
383
|
+
if (getCachedQueryVector(query)) return;
|
|
384
|
+
const now = Date.now();
|
|
385
|
+
if (now - lastPrefetch < 15_000) return;
|
|
386
|
+
lastPrefetch = now;
|
|
387
|
+
embedQueryCached(query, cfg.embedTimeoutMs).catch(() => {});
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// 首次 assemble 之后自动预热语义索引(allTools 只在 assemble 里才有值,
|
|
391
|
+
// 启动瞬间是空的)。每 15s 探一次,最多 5 分钟;unref 保证不阻止进程退出。
|
|
392
|
+
// 这样第一条 tools_search 之前索引通常已就绪,且不占启动时间。
|
|
393
|
+
let warmTicks = 0;
|
|
394
|
+
const warmTimer = setInterval(() => {
|
|
395
|
+
warmTicks += 1;
|
|
396
|
+
if (semIndex || semIndexPromise || warmTicks > 20) {
|
|
397
|
+
clearInterval(warmTimer);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (allTools.length) ensureSemanticIndex();
|
|
401
|
+
}, 15_000);
|
|
402
|
+
warmTimer.unref?.();
|
|
403
|
+
|
|
294
404
|
const fbPath = feedbackPath(cfg);
|
|
295
405
|
const feedback = loadFeedback(fbPath, logger);
|
|
296
406
|
let index = null;
|
|
@@ -384,9 +494,12 @@ export function apply(ctx, config) {
|
|
|
384
494
|
// P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
|
|
385
495
|
const searchCache = createSearchCache(200, 60000);
|
|
386
496
|
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
497
|
+
// serverOf is the module-level function defined above (mcp__server__tool →
|
|
498
|
+
// "mcp__server", openhands_* → "openhands", plain native tools → "native").
|
|
499
|
+
// P3-1 (2026-09-02): the previous apply-local copy (split("__").slice(0,2))
|
|
500
|
+
// let plain tools be their own "server", diverging from catalogText grouping
|
|
501
|
+
// and making sibling closure useless for native/openhands families. One
|
|
502
|
+
// definition, one口径.
|
|
390
503
|
|
|
391
504
|
// Pattern matching shared by deny (P0) and include (P2-1): exact name or
|
|
392
505
|
// "prefix*" matches any tool starting with that prefix.
|
|
@@ -460,6 +573,17 @@ export function apply(ctx, config) {
|
|
|
460
573
|
if (cfg.topK > 0 && index) {
|
|
461
574
|
for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
|
|
462
575
|
}
|
|
576
|
+
// v0.3.0: 语义腿参与自动注入。**只用已缓存的查询向量**——热路径绝不等网络,
|
|
577
|
+
// 所以这里不会拖慢 assemble,也不会撞 maxFoldMs。查询向量由 prefetchQuery
|
|
578
|
+
// 在后台预热(同一条消息的第二轮及以后通常已命中缓存)。
|
|
579
|
+
if (cfg.semanticDynamic && cfg.topK > 0 && semIndex?.available) {
|
|
580
|
+
try {
|
|
581
|
+
const hits = searchSemanticCached(semIndex.vectors, semanticDocs(), query);
|
|
582
|
+
for (const h of hits.slice(0, cfg.topK)) dyn.add(h.id);
|
|
583
|
+
} catch {
|
|
584
|
+
/* 语义腿失败绝不影响折叠 */
|
|
585
|
+
}
|
|
586
|
+
}
|
|
463
587
|
return dyn;
|
|
464
588
|
}
|
|
465
589
|
|
|
@@ -514,7 +638,7 @@ export function apply(ctx, config) {
|
|
|
514
638
|
if (!index) return { query: q, results: [], total: 0 };
|
|
515
639
|
// P0-②: 同 query 内容指纹的结果缓存——重复搜索返回同一结果集(漂移的
|
|
516
640
|
// 最小修)。进程级共享(非单会话隔离);空 query("*" 语义)不走缓存。
|
|
517
|
-
const cacheKey = `${lim}|${q}`;
|
|
641
|
+
const cacheKey = `${lim}|${q}|${indexStamp || ""}`;
|
|
518
642
|
if (q) {
|
|
519
643
|
const cached = searchCache.get(cacheKey);
|
|
520
644
|
if (cached) return cached;
|
|
@@ -528,23 +652,59 @@ export function apply(ctx, config) {
|
|
|
528
652
|
q,
|
|
529
653
|
search(index, eq, lim * 2).map((i) => ({ id: allTools[i].name })),
|
|
530
654
|
);
|
|
655
|
+
const byName = new Map(allTools.map((t) => [t.name, t]));
|
|
531
656
|
let fused = bm25Hits;
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
657
|
+
// 语义腿(v0.3.0):远程优先,加权 RRF。索引在后台构建——这里绝不等待
|
|
658
|
+
// 冷启动(否则一次搜索能卡 30s+),尚未就绪就先返回 BM25 结果,
|
|
659
|
+
// 同时由 ensureSemanticIndex 继续在后台补,下一次搜索自然用上。
|
|
535
660
|
if (cfg.semanticEnabled !== false) {
|
|
536
661
|
try {
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
662
|
+
ensureSemanticIndex();
|
|
663
|
+
if (semIndex?.available) {
|
|
664
|
+
const semHits = await searchSemantic(semIndex.vectors, semanticDocs(), eq);
|
|
665
|
+
// 自适应 BM25 权重:纯中文查询下 BM25 只有 CJK bigram 噪声,
|
|
666
|
+
// 降权 0.25;有真实英文锚点(工具名/server/关键词)时保持 1。
|
|
667
|
+
const topTool = bm25Hits[0] ? byName.get(bm25Hits[0].id) : null;
|
|
668
|
+
const bw =
|
|
669
|
+
cfg.adaptiveBm === false
|
|
670
|
+
? cfg.bmWeight
|
|
671
|
+
: adaptiveBmWeight(q, bm25Hits[0]?.id, topTool?.description);
|
|
672
|
+
fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim, {
|
|
673
|
+
semWeight: cfg.semanticWeight,
|
|
674
|
+
bmWeight: bw,
|
|
675
|
+
});
|
|
542
676
|
}
|
|
543
677
|
} catch (e) {
|
|
544
678
|
logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
|
|
545
679
|
}
|
|
546
680
|
}
|
|
547
|
-
|
|
681
|
+
|
|
682
|
+
// v0.3.0 正文精排(借鉴 SkillRouter 两阶段检索):在已召回候选集内用
|
|
683
|
+
// 完整描述 + 参数 schema 重排,**不扩大候选**(技能侧实测扩大候选会
|
|
684
|
+
// 让泛化词把无关项顶上来)。候选之外的 BM25/语义命中保持原序接在后面。
|
|
685
|
+
if (cfg.bodyRerank && fused.length > 1) {
|
|
686
|
+
try {
|
|
687
|
+
const topN = Math.min(cfg.bodyRerankTopN, fused.length);
|
|
688
|
+
const head = fused.slice(0, topN);
|
|
689
|
+
const tail = fused.slice(topN);
|
|
690
|
+
const reranked = rerankByBody(q, head, byName, { weight: cfg.bodyRerankWeight });
|
|
691
|
+
fused = [...reranked.map((r) => ({ id: r.id })), ...tail];
|
|
692
|
+
} catch (e) {
|
|
693
|
+
logger.warn("body rerank failed (%s) — keep fused order", e?.message);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 精确名/名字串命中永远钉在最前(正文重排也不许把它挤下去)。
|
|
698
|
+
const exact = bm25Hits.filter((h) => {
|
|
699
|
+
const name = String(h.id || "").toLowerCase();
|
|
700
|
+
const needle = q.trim().toLowerCase();
|
|
701
|
+
return needle && (name === needle || name.endsWith(needle) || name.includes(needle));
|
|
702
|
+
});
|
|
703
|
+
if (exact.length) {
|
|
704
|
+
const pinned = new Set(exact.map((h) => h.id));
|
|
705
|
+
fused = [...exact, ...fused.filter((h) => !pinned.has(h.id))];
|
|
706
|
+
}
|
|
707
|
+
|
|
548
708
|
let results = fused.map((h) => {
|
|
549
709
|
const t = byName.get(h.id) || {};
|
|
550
710
|
return {
|
|
@@ -659,11 +819,18 @@ export function apply(ctx, config) {
|
|
|
659
819
|
// Meta tools are exempt from include so a whitelist can never hide the
|
|
660
820
|
// discovery tools themselves (that would break the always-visible loop).
|
|
661
821
|
const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
);
|
|
822
|
+
// v0.3.0: 按名字去重。宿主的组装结果在极端情况下可能重复(同一工具被两个
|
|
823
|
+
// 插件注册),重复项会让 BM25/RRF 把同一 id 的分数算两次——实测能把正确项
|
|
824
|
+
// 从第一挤到第三以外。
|
|
825
|
+
const tools = [];
|
|
826
|
+
const seenToolNames = new Set();
|
|
827
|
+
for (const t of assembledTools) {
|
|
828
|
+
if (isDenied(t?.name)) continue;
|
|
829
|
+
if (!(isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns))) continue;
|
|
830
|
+
if (seenToolNames.has(t.name)) continue;
|
|
831
|
+
seenToolNames.add(t.name);
|
|
832
|
+
tools.push(t);
|
|
833
|
+
}
|
|
667
834
|
if (tools.length === 0) return { ...assembled, tools };
|
|
668
835
|
|
|
669
836
|
const agentId = context?.agent?.id || "default";
|
|
@@ -702,6 +869,10 @@ export function apply(ctx, config) {
|
|
|
702
869
|
}
|
|
703
870
|
|
|
704
871
|
const query = extractQuery(context);
|
|
872
|
+
// v0.3.0:语义索引在后台构建(首次冷启动),查询向量也后台预热。
|
|
873
|
+
// 两处都是 fire-and-forget:assemble 是热路径,绝不因为 embedding 变慢。
|
|
874
|
+
ensureSemanticIndex();
|
|
875
|
+
prefetchQuery(query);
|
|
705
876
|
const dynNames = buildDynamicSet(tools, query);
|
|
706
877
|
|
|
707
878
|
// v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
|
package/lib/obfuscation.js
CHANGED
|
@@ -65,7 +65,15 @@ const RE_BITSADMIN = /bitsadmin\s+\/transfer\s+\S+\s+\/download/i;
|
|
|
65
65
|
|
|
66
66
|
// A long base64 blob co-located with an execution verb in the same command.
|
|
67
67
|
const RE_B64_PAYLOAD = /[A-Za-z0-9+/]{40,}={0,2}/;
|
|
68
|
-
|
|
68
|
+
// Path fingerprint: >=3 camel-case segments joined by '/' (C:/Users/L/AppData/...).
|
|
69
|
+
// '/' is a base64 char, so long Windows/URL paths form fake base64 runs; real
|
|
70
|
+
// base64 payloads almost never contain 3+ `/Upper`-word segments. Fixed 2026-09-02
|
|
71
|
+
// (measured: 57-char WinGet path run + `2>&1` was blocked as base64-embedded-command).
|
|
72
|
+
const RE_PATHISH_RUN = /(?:\/[A-Z][A-Za-z]{1,}){3,}/;
|
|
73
|
+
// Exec verb used by the b64/hex legs. NOTE: no bare `&` — `2>&1` redirection
|
|
74
|
+
// and `& ` call operators are not payload-exec evidence by themselves; the
|
|
75
|
+
// verb must be an actual command word (fixed 2026-09-02, fp-repro 2/5).
|
|
76
|
+
const EXEC_VERB = /(?:iex|invoke-expression|invoke-command|iwr|irm|start-process|start-job|&\s*(?:iex|invoke|iwr|irm|cmd|powershell|pwsh|sh|bash|python|node|mshta|rundll32|regsvr32|wscript|cscript)|cmd\s*\/[cC]|sh\s+-c|bash\s+-c|python\s+-[cC]|node\s+-e|powershell|pwsh|mshta|rundll32|regsvr32|wscript|cscript)\b/i;
|
|
69
77
|
|
|
70
78
|
// Full-width Latin letters (curl) — never legit in a command line.
|
|
71
79
|
const RE_FULLWIDTH = /[\uFF21-\uFF3A\uFF41-\uFF5A]/;
|
|
@@ -97,7 +105,8 @@ export function detectObfuscation(rawText, cleanText) {
|
|
|
97
105
|
if (RE_BITSADMIN.test(probe)) {
|
|
98
106
|
return { label: "bitsadmin-download-exec", detail: "bitsadmin /transfer 下载执行" };
|
|
99
107
|
}
|
|
100
|
-
|
|
108
|
+
const b64Run = RE_B64_PAYLOAD.exec(probe);
|
|
109
|
+
if (b64Run && !RE_PATHISH_RUN.test(b64Run[0]) && EXEC_VERB.test(probe)) {
|
|
101
110
|
return { label: "base64-embedded-command", detail: "长 base64 载荷 + 执行动词共存" };
|
|
102
111
|
}
|
|
103
112
|
if (RE_FULLWIDTH.test(raw)) {
|
package/lib/rerank.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rerank.js — 正文精排(借鉴 SkillRouter 的「元数据召回 + 正文重排」两阶段)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么工具也需要「正文」:`tools_search` 的召回声只看 name + description,
|
|
5
|
+
* 而真正决定「该用哪个工具」的信息常常在**参数**里。例:
|
|
6
|
+
* mcp__playwright__browser_navigate vs browser_evaluate vs browser_take_screenshot
|
|
7
|
+
* 描述都是 "browser ...",参数才是 url / function / filename 的区别。
|
|
8
|
+
* 再如一堆 `*_search`:query 还是 keyword、有没有 repo/limit,全在参数描述里。
|
|
9
|
+
* L1 压缩还会把描述截到 200 字、参数描述砍到 120 字——被裁掉的正是这部分信号。
|
|
10
|
+
*
|
|
11
|
+
* 做法(与技能侧 v0.4.0 的 deep-search 同构):
|
|
12
|
+
* - 在 BM25/语义召回的**候选集内**重排,绝不扩大候选
|
|
13
|
+
* (技能侧实测:扩大候选会让泛化词把无关项顶上来,Hit@1 从 50% 掉到 25%)
|
|
14
|
+
* - body 打分 = 查询词在正文里的 IDF 覆盖率 + 整句命中加分
|
|
15
|
+
* - 融合:final = (1-w)·元数据名次分 + w·正文分
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { tokenize } from "./bm25.js";
|
|
19
|
+
|
|
20
|
+
const MAX_BODY_CHARS = 4000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 拼出一个工具的「正文」:完整描述(不截断)+ 参数名 + 参数描述 + 枚举 + server。
|
|
24
|
+
* @param {object} tool
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function toolBodyText(tool) {
|
|
28
|
+
if (!tool || typeof tool !== "object") return "";
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (tool.name) parts.push(String(tool.name));
|
|
31
|
+
if (tool.description) parts.push(String(tool.description));
|
|
32
|
+
const schema = tool.parameters || tool.inputSchema || null;
|
|
33
|
+
const props = schema && typeof schema === "object" ? schema.properties : null;
|
|
34
|
+
if (props && typeof props === "object") {
|
|
35
|
+
for (const [k, v] of Object.entries(props)) {
|
|
36
|
+
parts.push(k);
|
|
37
|
+
if (v && typeof v === "object") {
|
|
38
|
+
if (typeof v.title === "string") parts.push(v.title);
|
|
39
|
+
if (typeof v.description === "string") parts.push(v.description);
|
|
40
|
+
if (Array.isArray(v.enum)) parts.push(v.enum.slice(0, 12).join(" "));
|
|
41
|
+
if (v.items && typeof v.items === "object" && typeof v.items.description === "string") {
|
|
42
|
+
parts.push(v.items.description);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(schema.required) && schema.required.length) {
|
|
47
|
+
parts.push(`required ${schema.required.join(" ")}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (tool.server) parts.push(String(tool.server));
|
|
51
|
+
return parts.join(" ").slice(0, MAX_BODY_CHARS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 在候选集内按正文重排。
|
|
56
|
+
*
|
|
57
|
+
* @param {string} query
|
|
58
|
+
* @param {Array<{id: string}>} candidates 已按元数据名次排好序
|
|
59
|
+
* @param {Map<string, object>} byName
|
|
60
|
+
* @param {{weight?: number, bodyText?: Function}} [opts]
|
|
61
|
+
* @returns {Array<{id: string, score: number, metaRank: number, bodyScore: number}>}
|
|
62
|
+
*/
|
|
63
|
+
export function rerankByBody(query, candidates, byName, opts = {}) {
|
|
64
|
+
const list = Array.isArray(candidates) ? candidates.filter((c) => c && c.id) : [];
|
|
65
|
+
if (!list.length) return [];
|
|
66
|
+
const w = Number(opts.weight);
|
|
67
|
+
const weight = Number.isFinite(w) && w >= 0 && w <= 1 ? w : 0.35;
|
|
68
|
+
const bodyOf = typeof opts.bodyText === "function" ? opts.bodyText : toolBodyText;
|
|
69
|
+
|
|
70
|
+
const q = String(query || "").trim();
|
|
71
|
+
const qTokens = [...new Set(tokenize(q))];
|
|
72
|
+
const qLower = q.toLowerCase().replace(/\s+/g, " ");
|
|
73
|
+
|
|
74
|
+
if (!qTokens.length) {
|
|
75
|
+
return list.map((c, i) => ({ id: c.id, score: 1 / (1 + i), metaRank: i, bodyScore: 0 }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 候选集内的 df / idf(小集合上算,不依赖全局语料)
|
|
79
|
+
const bodies = new Map();
|
|
80
|
+
const df = new Map();
|
|
81
|
+
for (const c of list) {
|
|
82
|
+
const tool = byName.get(c.id);
|
|
83
|
+
const text = bodyOf(tool || { name: c.id });
|
|
84
|
+
const tokens = tokenize(text);
|
|
85
|
+
const set = new Set(tokens);
|
|
86
|
+
bodies.set(c.id, { text: text.toLowerCase(), tokens: set, len: tokens.length });
|
|
87
|
+
for (const t of set) df.set(t, (df.get(t) || 0) + 1);
|
|
88
|
+
}
|
|
89
|
+
const n = list.length;
|
|
90
|
+
const idf = (t) => Math.log((n - (df.get(t) || 0) + 0.5) / ((df.get(t) || 0) + 0.5) + 1);
|
|
91
|
+
// 分母只算「候选正文里真的出现过」的查询词。否则中文 bigram(工具描述多是英文,
|
|
92
|
+
// bigram 全 df=0)会把分母撑大,正文分被稀释到接近 0,精排等于没生效。
|
|
93
|
+
const matchedQ = qTokens.filter((t) => (df.get(t) || 0) > 0);
|
|
94
|
+
const qIdfSum = (matchedQ.length ? matchedQ.reduce((s, t) => s + idf(t), 0) : 0) || 1;
|
|
95
|
+
|
|
96
|
+
const scored = list.map((c, i) => {
|
|
97
|
+
const b = bodies.get(c.id);
|
|
98
|
+
if (!b) return { id: c.id, score: 1 / (1 + i), metaRank: i, bodyScore: 0 };
|
|
99
|
+
let hit = 0;
|
|
100
|
+
for (const t of qTokens) {
|
|
101
|
+
if (b.tokens.has(t)) hit += idf(t);
|
|
102
|
+
}
|
|
103
|
+
// 覆盖率 0..1,再乘一个「命中密度」修正:短正文命中更可信
|
|
104
|
+
let bodyScore = hit / qIdfSum;
|
|
105
|
+
if (bodyScore > 0 && b.len > 0) {
|
|
106
|
+
bodyScore *= 0.85 + 0.15 * Math.min(1, hit / Math.sqrt(b.len));
|
|
107
|
+
}
|
|
108
|
+
if (qLower.length >= 4 && b.text.includes(qLower)) bodyScore = Math.min(1, bodyScore + 0.15);
|
|
109
|
+
const metaNorm = 1 / (1 + i);
|
|
110
|
+
return { id: c.id, score: (1 - weight) * metaNorm + weight * bodyScore, metaRank: i, bodyScore };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
scored.sort((a, b) => b.score - a.score || a.metaRank - b.metaRank);
|
|
114
|
+
return scored;
|
|
115
|
+
}
|