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/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 { configureSemantic, buildSemanticIndex, searchSemantic, rrfFuse } from "./semantic.js";
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("语义检索腿(本地 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 时使用)"),
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, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
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
- // Semantic leg (P0-1): point the shared module at our config; the index is
280
- // built lazily on first tools_search call (doc embeddings cached on disk,
281
- // so the ~10s cold start happens once, then it's a cache hit).
282
- configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel });
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
- const getSemanticIndex = () => {
285
- if (!semIndexPromise) {
286
- semIndexPromise = (async () => {
287
- const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
288
- return buildSemanticIndex(docs);
289
- })();
290
- }
291
- return semIndexPromise;
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
- // "mcp__viking__remember" → "mcp__viking"; plain tools ("tool-bash") stay
388
- // as themselves (single segment survives slice(0,2)).
389
- const serverOf = (name) => String(name).split("__").slice(0, 2).join("__");
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
- // Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
533
- // Any failure (Ollama down / timeout / cache miss on error) degrades
534
- // to BM25-only — the hybrid must never be slower or worse than today.
657
+ // 语义腿(v0.3.0):远程优先,加权 RRF。索引在后台构建——这里绝不等待
658
+ // 冷启动(否则一次搜索能卡 30s+),尚未就绪就先返回 BM25 结果,
659
+ // 同时由 ensureSemanticIndex 继续在后台补,下一次搜索自然用上。
535
660
  if (cfg.semanticEnabled !== false) {
536
661
  try {
537
- const sem = await getSemanticIndex();
538
- if (sem?.available) {
539
- const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
540
- const semHits = await searchSemantic(sem.vectors, docs, eq);
541
- fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim);
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
- const byName = new Map(allTools.map((t) => [t.name, t]));
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
- const tools = assembledTools.filter(
663
- (t) =>
664
- !isDenied(t?.name) &&
665
- (isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns)),
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 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
@@ -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
- const EXEC_VERB = /(?:iex|invoke-expression|invoke-command|iwr|irm|start-process|start-job|&|cmd\s*\/[cC]|sh\s+-c|bash\s+-c|python\s+-[cC]|node\s+-e|powershell|pwsh|mshta|rundll32|regsvr32|wscript|cscript)\b/i;
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
- if (RE_B64_PAYLOAD.test(probe) && EXEC_VERB.test(probe)) {
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
+ }