dsh-tool-folder 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -149,6 +149,49 @@ were never unregistered, so nothing else changes.
149
149
 
150
150
  ## Changelog
151
151
 
152
+ ### v0.2.3 — fold-ux: search fallback catalog + query expansion + stable results + discovery stats (2026-08-30)
153
+ 1. **Zero-match fallback catalog** (`fallbackCatalogSize`, default 30): when
154
+ `tools_search` finds nothing, it returns a name-sorted lightweight catalog
155
+ (name + 80-char description) instead of an empty list — the model never
156
+ concludes a capability doesn't exist just because its wording missed.
157
+ `total` reports the real catalog size (not the listed slice); the output
158
+ carries `fallback: true`. `0` restores the legacy empty result.
159
+ 2. **CN→EN query expansion** (`queryExpandEnabled`, default on): a 48-term
160
+ high-frequency intent table appends English synonyms to the search text
161
+ (search/find, file, image/ocr, database, deploy, schedule…). Only adds
162
+ terms, never rewrites the original query; exact-name priority still uses
163
+ the raw query, so precise lookups are byte-identical to v0.2.2.
164
+ 3. **Stable results** (`createSearchCache`, 60s TTL / LRU 200): the same query
165
+ returns the same result set across turns — kills per-turn candidate drift.
166
+ 4. **Discovery stats** (`recordDiscoveries`): every found tool is counted and
167
+ persisted (30s-throttled, merged into tool-folder-state.json); on restart
168
+ the top-5 are logged so low-discovery tools can be identified and their
169
+ descriptions improved (Anthropic's "monitor discovery rate" loop).
170
+ 5. 83 tests green (11 new: ux module).
171
+ 6. Three-axis audit (Standards / Spec / compatibility): 1 P1 fixed — warm and
172
+ discovery state now merge-write the shared state file instead of clobbering
173
+ each other; P2 hardening — `tools_search` execute got a whole-body
174
+ fail-open catch, fallback `total` semantics clarified, comments corrected
175
+ to process-level scope.
176
+
177
+ ### v0.2.2 — tiered disclosure budget + warm LRU + incremental semantic cache (2026-08-30)
178
+ 1. **Tiered disclosure budget** (`disclosureBudget`, default 0 = off): caps how
179
+ many tool names + descriptions are disclosed per request. Over budget, tools
180
+ are demoted tier by tier (T2 name+desc → T3 name-only → T4 hidden) instead
181
+ of being dropped wholesale; core/hot/meta tools stay tier-1 and never demote.
182
+ 2. **Warm LRU** (`maxWarmTools`, default 0 = off): recently used tools are kept
183
+ visible within a bounded LRU, persisted to state so a restart keeps them
184
+ warm. `0` keeps the exact legacy injection surface.
185
+ 3. **Incremental semantic cache**: the bge-m3 embedding cache now diffs by
186
+ content fingerprint — only docs whose text changed get re-embedded, and a
187
+ model change rebuilds once. A failed refresh never clobbers a good cache.
188
+ 4. **Config persistence**: `lib/storage.js` reads/writes state with a
189
+ settings → state → memory fallback chain (never touches cordis.patch.yml).
190
+ 5. **Explicit fail-open**: `missingMetaTools` gates meta-tool availability —
191
+ any snapshot oddity returns the full tool list rather than a folded one.
192
+ 6. 72 tests green (new: budget 8 / warm 8 / semantic-incremental 5 / storage 7
193
+ / fail-open 4).
194
+
152
195
  ### v0.2.1 — heat decay + exact-name priority (2026-08-30)
153
196
  1. **Hot heat decay** (`hotWindowDays`, default 3): a tool is promoted to
154
197
  always-visible only when its call count stays inside the sliding window —
package/lib/budget.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * budget.js — 分级披露预算(v0.2.2,借鉴 Letter2025/dsh-tool-search)。
3
+ *
4
+ * 现状背景:折叠后工具面大小 = core + hot + 动态 topK(6),固定裁剪,无预算感知。
5
+ * 本模块引入 `disclosureBudget`:开启后(>0),当可见工具 schema 的估算字节
6
+ * 超过预算时,按「溢出降级」:
7
+
8
+ * 层级1(默认) 完整 schema(当前行为)
9
+ * 层级2 name + description(描述压缩至 100 字符)
10
+ * 层级3 name(一行一个)
11
+ * 层级4 整组折叠进 catalog(不注入,靠 tools_search 发现)
12
+
13
+ * 关键约定(P0 红线):
14
+ * - 降级只作用于「动态段」(dynTools);core/hot 永不降级(由调用方把
15
+ * core+hot 名字传作 protectedNames)。
16
+ * - 全部可降级工具按同一全局层级降级("降级层级"语义)。即使层级 4 仍超预算,
17
+ * 也绝不降级 protected——预算是对动态段的软约束,core/hot 恒为层级 1。
18
+ * - budget <= 0(含 undefined)视为「关闭」,全部返回层级 1(行为不变)。
19
+ *
20
+ * 字节估算(本模块的实现选择,写进注释作为契约):
21
+ * - 层级1:JSON.stringify(tool).length(完整 schema 序列化字节数)
22
+ * - 层级2:name.length + min(desc,100).length + 2(引号/空格开销)
23
+ * - 层级3:name.length + 1(一行一个 name)
24
+ * - 层级4:0(折叠进 catalog,本体不注入)
25
+ * 说明:JSON 序列化(而非 str_len 估算)更贴近实际 token 成本,选它作为层级1
26
+ * 的主衡准;层级2/3 是压缩形态,直接用字段长度。
27
+ */
28
+
29
+ /** 层级 2 描述截断上限 */
30
+ const DESC_CAP = 100;
31
+
32
+ /**
33
+ * 估算一批工具在给定层级的注入字节数。
34
+ * @param {Array<object>} tools tool defs(含 name/description)
35
+ * @param {number} tier 1|2|3|4
36
+ * @returns {number}
37
+ */
38
+ export function estimateBytes(tools, tier) {
39
+ let total = 0;
40
+ for (const t of tools) {
41
+ if (tier === 1) {
42
+ try {
43
+ total += JSON.stringify(t).length;
44
+ } catch {
45
+ total += String(t.name || "").length + 4;
46
+ }
47
+ } else if (tier === 2) {
48
+ total += String(t.name || "").length + Math.min(String(t.description || "").length, DESC_CAP) + 2;
49
+ } else if (tier === 3) {
50
+ total += String(t.name || "").length + 1;
51
+ }
52
+ // tier 4 → 0 字节(不注入)
53
+ }
54
+ return total;
55
+ }
56
+
57
+ /**
58
+ * 计算可见工具面的降级层级与每工具层级。
59
+ * @param {Array<object>} visible 折叠后的可见工具(可能已达 core+hot+动态)
60
+ * @param {number} [budget] disclosureBudget(<=0 = 关闭)
61
+ * @param {Set<string>|Array<string>} [protectedNames] core+hot 名字,永不降级
62
+ * @returns {{tier: number, byName: Record<string, number>}}
63
+ */
64
+ export function tierForBudget(visible, budget, protectedNames = []) {
65
+ const list = Array.isArray(visible) ? visible : [];
66
+ const prot = new Set(Array.isArray(protectedNames) ? protectedNames : Array.from(protectedNames || []));
67
+ const degrade = list.filter((t) => !prot.has(t.name));
68
+
69
+ const byName = {};
70
+ for (const t of list) byName[t.name] = 1;
71
+
72
+ // budget <= 0 = 关闭 → 不降级
73
+ if (!(budget > 0)) return { tier: 1, byName };
74
+
75
+ const protectedTools = list.filter((t) => prot.has(t.name));
76
+
77
+ // 无可降级的动态段(全 core/hot)→ 无从降级,恒层级 1
78
+ if (degrade.length === 0) return { tier: 1, byName };
79
+
80
+ // 全量仍在预算内 → 层级 1
81
+ if (estimateBytes(list, 1) <= budget) return { tier: 1, byName };
82
+
83
+ // 从层级 2 起逐级找第一个放得下的层级(层级 4 恒允许——折叠本体不注入)
84
+ for (let tier = 2; tier <= 3; tier++) {
85
+ const cost = estimateBytes(protectedTools, 1) + estimateBytes(degrade, tier);
86
+ if (cost <= budget) {
87
+ for (const t of degrade) byName[t.name] = tier;
88
+ return { tier, byName };
89
+ }
90
+ }
91
+ // 层级 4:动态段整体折叠
92
+ for (const t of degrade) byName[t.name] = 4;
93
+ return { tier: 4, byName };
94
+ }
package/lib/index.js CHANGED
@@ -33,7 +33,10 @@ import path from "node:path";
33
33
  import z from "@deepseek-ai/schemastery";
34
34
  import { buildIndex, search } from "./bm25.js";
35
35
  import { compressTool, normalizeDescription, sanitizeLossless } from "./schema.js";
36
- import { selectionCoverage, isHotTool, prioritizeExact } from "./metrics.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 } from "./ux.js";
37
40
  import { ChainGuard, verdict } from "./chainguard.js";
38
41
  import { matchCategory } from "./category.js";
39
42
  import { toonifyValue } from "./toonify.js";
@@ -77,6 +80,14 @@ export const Config = z.object({
77
80
  semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
78
81
  ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
79
82
  embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
83
+ disclosureBudget: z.number().min(0).max(200000).default(0)
84
+ .description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
85
+ maxWarmTools: z.number().min(0).max(50).default(0)
86
+ .description("warm LRU 上限:tools_search/schema 发现或折叠后被调用的工具进入 warm 集合,下一轮注入(完整 schema);达上限逐出最久未用;0=关闭(默认关,无配置升级逐字节等价,显式开启才启用)"),
87
+ fallbackCatalogSize: z.number().min(0).max(200).default(30)
88
+ .description("P0: tools_search 零匹配时回退的轻目录条数(name+80字描述,名字典序);0=关闭回退(返回空=旧行为)"),
89
+ queryExpandEnabled: z.boolean().default(true)
90
+ .description("P0: 中文意图→英文同义词扩展(只加词不改原词,扩大 BM25/语义召回;精确名匹配不受影响)"),
80
91
  });
81
92
 
82
93
  const DEFAULTS = {
@@ -111,6 +122,10 @@ const DEFAULTS = {
111
122
  semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
112
123
  ollamaBase: "http://127.0.0.1:11434",
113
124
  embedModel: "bge-m3",
125
+ disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
126
+ maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
127
+ fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
128
+ queryExpandEnabled: true, // P0: 中文→英文 query 扩展
114
129
  };
115
130
 
116
131
  function feedbackPath(cfg) {
@@ -282,6 +297,90 @@ export function apply(ctx, config) {
282
297
  const disposers = [];
283
298
  const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
284
299
 
300
+ /* ------------------------------------------------------------------ */
301
+ /* v0.2.2: warm LRU + 配置三级持久化 */
302
+ /* ------------------------------------------------------------------ */
303
+ // warm:Map<name, {last}>,LRU 顺序由 last 单调递增序号决定。被
304
+ // tools_search 搜到 / tools_schema 展开 / 折叠后仍被调用的工具进入集合,
305
+ // 下一轮注入完整 schema。maxWarmTools=0 时整个 warm 失效(仍不 touch)。
306
+ const warm = new Map();
307
+ const warmEnabled = cfg.maxWarmTools > 0;
308
+ const touchWarmNames = (names) => {
309
+ if (!warmEnabled || !Array.isArray(names) || names.length === 0) return;
310
+ touchWarm(warm, names, Date.now(), cfg.maxWarmTools);
311
+ persistWarmState();
312
+ };
313
+
314
+ // 三级持久化:① feedbackPath 同目录(首选)→ ② ~/.dsh/state/ → ③ 进程内。
315
+ // ⚠️ 绝不写 cordis.patch.yml(宿主配置,插件只读宿主、只落自有 state 文件)。
316
+ const primaryStateFile = () => path.join(path.dirname(fbPath), "tool-folder-state.json");
317
+ const fallbackStateFile = () => {
318
+ const home = process.env.DSH_HOME || process.env.USERPROFILE || process.env.HOME || "";
319
+ return home ? `${home}/.dsh/state/tool-folder-state.json` : "tool-folder-state.json";
320
+ };
321
+ let activeStateFile = primaryStateFile();
322
+ const restoreWarm = () => {
323
+ if (!warmEnabled) return; // 关闭态绝不载入磁盘残留(Spec 2: maxWarmTools=0=彻底关闭)
324
+ let cloud = readState(activeStateFile, null);
325
+ if (!cloud) {
326
+ activeStateFile = fallbackStateFile();
327
+ cloud = readState(activeStateFile, null);
328
+ }
329
+ if (cloud && Array.isArray(cloud.warm)) {
330
+ for (const [name, meta] of cloud.warm) {
331
+ if (typeof name === "string" && meta && typeof meta.last === "number") {
332
+ warm.set(name, { last: meta.last });
333
+ }
334
+ }
335
+ evictWarm(warm, cfg.maxWarmTools); // 载入即按上限裁剪
336
+ }
337
+ };
338
+ const persistWarmState = () => {
339
+ if (!warmEnabled) return;
340
+ // merge 写:与 discoveries 共用 state 文件,整写会互抹(Standards 轴 P1)。
341
+ const payload = { ...readState(activeStateFile, {}), warm: [...warm.entries()], ts: Date.now() };
342
+ let ok = writeState(activeStateFile, payload);
343
+ if (!ok) {
344
+ // 首选目录不可写 → 降到 ~/.dsh/state/
345
+ activeStateFile = fallbackStateFile();
346
+ writeState(activeStateFile, payload);
347
+ }
348
+ };
349
+ restoreWarm();
350
+
351
+ // P0-④: 发现率统计(独立于 warm 门控——maxWarmTools=0 时统计照常工作)。
352
+ // 与 warm 共用同一个 state 文件;落盘节流 30s(工具输出非注入面,频率低)。
353
+ let discoveries = new Map();
354
+ let lastPersistTs = 0;
355
+ const restoreDiscoveries = () => {
356
+ let cloud = readState(activeStateFile, null);
357
+ if (!cloud) {
358
+ activeStateFile = fallbackStateFile();
359
+ cloud = readState(activeStateFile, null);
360
+ }
361
+ discoveries = deserializeDiscoveries(cloud && Array.isArray(cloud.discoveries) ? cloud.discoveries : null);
362
+ };
363
+ const persistDiscoveries = () => {
364
+ const now = Date.now();
365
+ if (now - lastPersistTs < 30000) return; // 节流:30s 内只写一次
366
+ lastPersistTs = now;
367
+ // merge 写:与 warm 共用 state 文件,整写会互抹(Standards 轴 P1)。
368
+ const payload = { ...readState(activeStateFile, {}), discoveries: serializeDiscoveries(discoveries), ts: now };
369
+ let ok = writeState(activeStateFile, payload);
370
+ if (!ok) {
371
+ activeStateFile = fallbackStateFile();
372
+ writeState(activeStateFile, payload);
373
+ }
374
+ };
375
+ restoreDiscoveries();
376
+ if (discoveries.size > 0) {
377
+ const top = serializeDiscoveries(discoveries, 5).map((e) => `${e.name}(${e.count})`).join(", ");
378
+ logger.info("discovery stats restored: %d tools, top: %s", discoveries.size, top);
379
+ }
380
+
381
+ // P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
382
+ const searchCache = createSearchCache(200, 60000);
383
+
285
384
  // "mcp__viking__remember" → "mcp__viking"; plain tools ("tool-bash") stay
286
385
  // as themselves (single segment survives slice(0,2)).
287
386
  const serverOf = (name) => String(name).split("__").slice(0, 2).join("__");
@@ -382,6 +481,7 @@ export function apply(ctx, config) {
382
481
  },
383
482
  },
384
483
  total: { type: "integer", required: true },
484
+ fallback: { type: "boolean", description: "true = 零匹配,结果回退为按名字典序的轻目录" },
385
485
  },
386
486
  };
387
487
  const toolsSearch = defineTool({
@@ -403,15 +503,27 @@ export function apply(ctx, config) {
403
503
  output: { schema: outputSchema, render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }] },
404
504
  isConcurrencySafe: () => true,
405
505
  async execute(args) {
406
- const q = String(args.query || "");
407
- const lim = args.limit ?? 8;
408
- if (!index || !q) return { query: q, results: [], total: 0 };
506
+ // 整体 fail-open 兜底(Spec 轴 P2):任何未来 helper 意外抛错 → 空结果
507
+ // 而非硬失败,保持"折叠绝不误伤能力"的底线。
508
+ try {
509
+ const q = String(args.query || "");
510
+ const lim = args.limit ?? 8;
511
+ if (!index) return { query: q, results: [], total: 0 };
512
+ // P0-②: 同 query 内容指纹的结果缓存——重复搜索返回同一结果集(漂移的
513
+ // 最小修)。进程级共享(非单会话隔离);空 query("*" 语义)不走缓存。
514
+ const cacheKey = `${lim}|${q}`;
515
+ if (q) {
516
+ const cached = searchCache.get(cacheKey);
517
+ if (cached) return cached;
518
+ }
519
+ // P0-③: 中文意图→英文同义词扩展(只加词不改原词;精确名匹配用原 q)。
520
+ const eq = cfg.queryExpandEnabled !== false ? expandQuery(q) : q;
409
521
  // v0.2.1: exact-name > name-substring > BM25 priority (deterministic
410
522
  // fallback chain, borrowed from Hermes-style tool search).
411
523
  const bm25Hits = prioritizeExact(
412
524
  allTools,
413
525
  q,
414
- search(index, q, lim * 2).map((i) => ({ id: allTools[i].name })),
526
+ search(index, eq, lim * 2).map((i) => ({ id: allTools[i].name })),
415
527
  );
416
528
  let fused = bm25Hits;
417
529
  // Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
@@ -422,7 +534,7 @@ export function apply(ctx, config) {
422
534
  const sem = await getSemanticIndex();
423
535
  if (sem?.available) {
424
536
  const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
425
- const semHits = await searchSemantic(sem.vectors, docs, q);
537
+ const semHits = await searchSemantic(sem.vectors, docs, eq);
426
538
  fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim);
427
539
  }
428
540
  } catch (e) {
@@ -430,18 +542,39 @@ export function apply(ctx, config) {
430
542
  }
431
543
  }
432
544
  const byName = new Map(allTools.map((t) => [t.name, t]));
433
- return {
545
+ let results = fused.map((h) => {
546
+ const t = byName.get(h.id) || {};
547
+ return {
548
+ name: h.id,
549
+ description: (t.description || "").slice(0, 160),
550
+ server: serverOf(h.id),
551
+ };
552
+ });
553
+ // P0-①: 零匹配回退轻目录(name+80字描述,名字典序)——根治「搜词不对
554
+ // 就以为没有该能力」。fallbackCatalogSize=0 关闭(返回空=旧行为)。
555
+ // total 在回退时=全量目录大小(而非列出条数),避免模型误以为目录总容量=limit。
556
+ let fallback = false;
557
+ if (results.length === 0 && cfg.fallbackCatalogSize > 0) {
558
+ results = fallbackCatalog(allTools, cfg.fallbackCatalogSize, serverOf);
559
+ fallback = true;
560
+ }
561
+ // v0.2.2: 被搜到的工具名进入 warm 集合,下一轮注入完整 schema。
562
+ touchWarmNames(results.map((r) => r.name));
563
+ // P0-④: 发现率统计(节流落盘,低频工具名单反哺描述改进)。
564
+ recordDiscoveries(discoveries, results.map((r) => r.name));
565
+ persistDiscoveries();
566
+ const out = {
434
567
  query: q,
435
- results: fused.map((h) => {
436
- const t = byName.get(h.id) || {};
437
- return {
438
- name: h.id,
439
- description: (t.description || "").slice(0, 160),
440
- server: serverOf(h.id),
441
- };
442
- }),
443
- total: fused.length,
568
+ results,
569
+ total: fallback ? allTools.length : results.length,
570
+ ...(fallback ? { fallback: true } : {}),
444
571
  };
572
+ if (q) searchCache.set(cacheKey, out);
573
+ return out;
574
+ } catch (e) {
575
+ logger.warn("tools_search failed (%s) — empty results", e?.message);
576
+ return { query: String(args.query || ""), results: [], total: 0 };
577
+ }
445
578
  },
446
579
  });
447
580
  try {
@@ -484,6 +617,8 @@ export function apply(ctx, config) {
484
617
  const name = String(args?.name || "");
485
618
  const t = allTools.find((x) => x.name === name);
486
619
  if (!t) return { name, found: false, reason: "unknown tool" }; // never throw
620
+ // v0.2.2: 展开过 schema 的工具名进入 warm 集合,下一轮保持注入。
621
+ touchWarmNames([name]);
487
622
  return {
488
623
  name: t.name,
489
624
  found: true,
@@ -564,6 +699,11 @@ export function apply(ctx, config) {
564
699
  const query = extractQuery(context);
565
700
  const dynNames = buildDynamicSet(tools, query);
566
701
 
702
+ // v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
703
+ const warmNamesSet = new Set(
704
+ [...warm.keys()].filter((n) => tools.some((t) => t.name === n)),
705
+ );
706
+
567
707
  // L5: stable ordering — core by config order, hot by name, dyn by name.
568
708
  // A deterministic byte order keeps the prompt prefix cacheable.
569
709
  const coreTools = tools.filter((t) => coreNames.has(t.name));
@@ -575,12 +715,24 @@ export function apply(ctx, config) {
575
715
  (t) =>
576
716
  !coreNames.has(t.name) &&
577
717
  !hotNames.has(t.name) &&
718
+ !warmNamesSet.has(t.name) &&
578
719
  dynNames.has(t.name),
579
720
  )
580
721
  .sort((a, b) => (a.name < b.name ? -1 : 1));
581
722
 
723
+ // v0.2.2: warm = 上一轮被 tools_search/schema 发现或折叠后被调用的工具。
724
+ // 属主动晋升(接近 hot),完整 schema 注入,不作为 budget 可降级段。
725
+ const warmTools = tools
726
+ .filter(
727
+ (t) =>
728
+ !coreNames.has(t.name) &&
729
+ !hotNames.has(t.name) &&
730
+ warmNamesSet.has(t.name),
731
+ )
732
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
733
+
582
734
  // L1: schema compression + description normalization on the dynamic
583
- // segment only. core/hot never pass through this pipe (P0 red line).
735
+ // segment only. core/hot/warm never pass through this pipe (P0 red line).
584
736
  const trimmed =
585
737
  effectiveLevel !== "off" || cfg.normalizeDescriptions
586
738
  ? dynTools.map((t) => {
@@ -594,7 +746,40 @@ export function apply(ctx, config) {
594
746
  })
595
747
  : dynTools;
596
748
 
597
- const visible = [...coreTools, ...hotTools, ...trimmed];
749
+ // v0.2.2 分级披露预算:动态段(trimmed)是唯一可降级段。core/hot/warm
750
+ // 作为 protected 恒层级 1。tierForBudget 返回每个可见名字的降级层级;
751
+ // 层级 2/3 组装压缩形态,层级 4 不再注入(由 catalog 兜底发现)。
752
+ let visible = [...coreTools, ...hotTools, ...warmTools, ...trimmed];
753
+ if (cfg.disclosureBudget > 0) {
754
+ const protectedNames = new Set([
755
+ ...coreNames,
756
+ ...hotNames,
757
+ ...warmNamesSet,
758
+ // meta 工具永远完整可见
759
+ "tools_search",
760
+ "tools_schema",
761
+ ]);
762
+ const tiers = tierForBudget(visible, cfg.disclosureBudget, protectedNames);
763
+ // Effective budget = min(disclosureBudget, context estimate). We do not
764
+ // separately measure context here, so the hard disclosureBudget stands.
765
+ // Rebuild visible honoring per-tool tiers (they are already ordered).
766
+ visible = visible.map((t) => {
767
+ const tier = tiers.byName[t.name] || 1;
768
+ if (tier === 1) return t; // 完整 schema
769
+ if (tier === 2) {
770
+ // name + description(描述压缩至 100 字符)
771
+ return {
772
+ name: t.name,
773
+ description: String(t.description || "").slice(0, 100),
774
+ };
775
+ }
776
+ if (tier === 3) {
777
+ // 仅 name(一行一个)
778
+ return { name: t.name };
779
+ }
780
+ return null; // 层级 4:折叠进 catalog,不注入
781
+ }).filter(Boolean);
782
+ }
598
783
 
599
784
  // The meta-tools must always stay visible or the model can never ask
600
785
  // for folded tools (they would be hidden by their own fold).
@@ -609,6 +794,21 @@ export function apply(ctx, config) {
609
794
  }
610
795
  }
611
796
 
797
+ // v0.2.2 fail-open 硬保证(借鉴 fan56/dsh-mcp-adapter):启用的发现
798
+ // 元工具仍缺失(注册失败/名冲突/部分启动)→ 折叠后工具不可发现,
799
+ // 比不折叠更糟——显式回退官方全量工具列表,绝不静默吞掉发现路径。
800
+ const missingMeta = missingMetaTools(
801
+ new Set(visible.map((t) => t.name)),
802
+ { tools_search: cfg.toolSearchEnabled, tools_schema: cfg.schemaToolEnabled },
803
+ );
804
+ if (missingMeta.length > 0) {
805
+ logger.warn(
806
+ "fail-open: meta tool(s) %s missing after fold — falling back to full tool list",
807
+ missingMeta.join(","),
808
+ );
809
+ return next();
810
+ }
811
+
612
812
  const visibleNames = visible.map((t) => t.name);
613
813
  lastVisible = { agentId, names: visibleNames, ts: Date.now() };
614
814
 
@@ -619,6 +819,7 @@ export function apply(ctx, config) {
619
819
  !isMetaTool(t.name) &&
620
820
  !coreNames.has(t.name) &&
621
821
  !hotNames.has(t.name) &&
822
+ !warmNamesSet.has(t.name) &&
622
823
  !dynNames.has(t.name),
623
824
  );
624
825
  if (folded.length > 0) {
@@ -717,6 +918,8 @@ export function apply(ctx, config) {
717
918
  "folded tools called (coverage=%.2f): %s — promoting via heat",
718
919
  cov.coverage, cov.uncovered.join(", "),
719
920
  );
921
+ // v0.2.2: 折叠后仍被调用的工具名也进 warm,下一轮避免再折叠。
922
+ touchWarmNames(cov.uncovered);
720
923
  }
721
924
 
722
925
  // L3: record the last 50 sessions for offline evaluation.
package/lib/metrics.js CHANGED
@@ -60,6 +60,24 @@ export function selectionCoverage(visibleNames, calledNames) {
60
60
  };
61
61
  }
62
62
 
63
+ /**
64
+ * Fail-open gate (v0.2.2, borrowed from fan56/dsh-mcp-adapter): the discovery
65
+ * meta-tools must be visible or folded tools become undiscoverable — which is
66
+ * worse than not folding at all. Returns the names of ENABLED meta-tools that
67
+ * are missing from the visible set (registration failure / name conflict).
68
+ * @param {Set<string>|Array<string>} visibleNames - names in the folded surface.
69
+ * @param {object} enabled - { toolName: boolean } enabled flags.
70
+ * @returns {string[]} missing enabled meta-tool names ([] = all present).
71
+ */
72
+ export function missingMetaTools(visibleNames, enabled) {
73
+ const vis = visibleNames instanceof Set ? visibleNames : new Set(visibleNames || []);
74
+ const out = [];
75
+ for (const [n, on] of Object.entries(enabled || {})) {
76
+ if (on && !vis.has(n)) out.push(n);
77
+ }
78
+ return out;
79
+ }
80
+
63
81
  /**
64
82
  * Hot-promotion gate with sliding-window decay (v0.2.1).
65
83
  * feedback.calls entries may be:
@@ -87,6 +105,69 @@ export function isHotTool(calls, name, hotThreshold, windowMs) {
87
105
  return true;
88
106
  }
89
107
 
108
+ /* ------------------------------------------------------------------ */
109
+ /* warm LRU 预热集合(v0.2.2,借鉴 Letter2025/dsh-tool-search) */
110
+ /* */
111
+ /* 现状背景:折叠后模型用 tools_search / tools_schema 发现工具,但发现 */
112
+ /* 后该 schema 不保留在注入面,下一轮又折叠。warm 集合把「被搜到的」 */
113
+ /* 「被展开 schema 的」「被折叠却仍被调用的」工具名暂存进一个进程内 */
114
+ /* LRU Map,下一轮注入(完整或按当前降级层级)。 */
115
+ /* */
116
+ /* 数据结构:Map<name, { last: number }>——last 单调递增(用毫秒时间 */
117
+ /* 戳或调用方给的递增序号),LRU 即按 last 升序逐出最久未用。注意 */
118
+ /* Map 的自身插入序不可用作 LRU 序(Map 本身有序,但 touch 已存在键 */
119
+ /* 不改变插入位置),显式 last 字段才是判据。 */
120
+ /* ------------------------------------------------------------------ */
121
+
122
+ /**
123
+ * 把一组名字 touch 进 warm 集合。已存在则刷新 last(置为最新,不重复),
124
+ * 不存在则新增。可随 last 一起把上限也一次收进来——调用方可在此后自行
125
+ * evictWarm,或依赖后续 evictWarm。
126
+ * @param {Map<string,{last:number}>} warm 进程内 warm Map
127
+ * @param {Array<string>} names
128
+ * @param {number} now 单调递增时钟(如 Date.now())
129
+ * @param {number} [max] 可选:touch 后若超限自动逐出
130
+ */
131
+ export function touchWarm(warm, names, now, max) {
132
+ const list = Array.isArray(names) ? names.filter(Boolean) : [];
133
+ for (const n of list) {
134
+ // 用「单调递增序号 = max(now, 上一次+1)」保证同一批内/跨批绝不并列:
135
+ // 每次 touch 都取得严格大于 warm 里现有最大 last 的序号,LRU 判据稳定。
136
+ let next = now;
137
+ for (const v of warm.values()) {
138
+ if (typeof v === "object" && typeof v.last === "number" && v.last >= next) next = v.last + 1;
139
+ }
140
+ warm.set(n, { last: next });
141
+ }
142
+ if (typeof max === "number" && max > 0 && warm.size > max) evictWarm(warm, max);
143
+ }
144
+
145
+ /**
146
+ * 逐出最久未用直到 size <= max。max<=0 = 关闭(不逐出)。
147
+ * @param {Map<string,{last:number}>} warm
148
+ * @param {number} max
149
+ * @returns {Array<string>} 被逐出的名字(仅信息用途)
150
+ */
151
+ export function evictWarm(warm, max) {
152
+ const evicted = [];
153
+ if (!(max > 0)) return evicted;
154
+ while (warm.size > max) {
155
+ let oldest = null;
156
+ let oldestLast = Infinity;
157
+ for (const [name, v] of warm) {
158
+ const l = v && typeof v.last === "number" ? v.last : 0;
159
+ if (l < oldestLast) {
160
+ oldestLast = l;
161
+ oldest = name;
162
+ }
163
+ }
164
+ if (oldest == null) break;
165
+ warm.delete(oldest);
166
+ evicted.push(oldest);
167
+ }
168
+ return evicted;
169
+ }
170
+
90
171
  /**
91
172
  * Deterministic retrieval priority (v0.2.1, borrowed from Hermes-style
92
173
  * "exact name > name token > description" fallback chains): the query's
@@ -111,14 +111,3 @@ export function detectObfuscation(rawText, cleanText) {
111
111
  }
112
112
  return null;
113
113
  }
114
-
115
- /**
116
- * Combined check used by verdict(): returns the first obfuscation hit, or
117
- * null when the text looks clean.
118
- */
119
- export function checkObfuscation(text) {
120
- const raw = String(text || "");
121
- if (!raw) return null;
122
- const clean = deobfuscate(raw);
123
- return detectObfuscation(raw, clean);
124
- }
package/lib/semantic.js CHANGED
@@ -23,6 +23,8 @@ const EMBED_TIMEOUT_MS = 8000;
23
23
  const CACHE_FILE = ".dsh/state/semantic-cache.json";
24
24
 
25
25
  import fs from "node:fs";
26
+ import path from "node:path";
27
+ import crypto from "node:crypto";
26
28
 
27
29
  /* ------------------------------------------------------------------ */
28
30
  /* hash + fs helpers (all fail-safe) */
@@ -39,13 +41,25 @@ function hash(text) {
39
41
  return h.toString(16).padStart(8, "0");
40
42
  }
41
43
 
44
+ /** sha1 hex — per-doc content fingerprint for incremental diff (v0.2.2). */
45
+ function sha1(text) {
46
+ try {
47
+ return crypto.createHash("sha1").update(String(text || ""), "utf8").digest("hex");
48
+ } catch {
49
+ return hash(text); // node:crypto 不可用时的降级指纹
50
+ }
51
+ }
52
+
42
53
  function homeDir() {
43
54
  return process.env.USERPROFILE || process.env.HOME || "";
44
55
  }
45
56
 
46
- function readCacheFile() {
57
+ function defaultCachePath() {
58
+ return homeDir() + "/" + CACHE_FILE;
59
+ }
60
+
61
+ function readCacheFileAt(p) {
47
62
  try {
48
- const p = homeDir() + "/" + CACHE_FILE;
49
63
  if (!p || !fs.existsSync(p)) return null;
50
64
  const raw = fs.readFileSync(p, "utf8");
51
65
  const j = JSON.parse(raw);
@@ -56,11 +70,10 @@ function readCacheFile() {
56
70
  return null;
57
71
  }
58
72
 
59
- function writeCacheFile(obj) {
73
+ function writeCacheFileAt(p, obj) {
60
74
  try {
61
- const p = homeDir() + "/" + CACHE_FILE;
62
- const dir = p.slice(0, p.lastIndexOf("/"));
63
- fs.mkdirSync(dir, { recursive: true });
75
+ if (!p) return;
76
+ fs.mkdirSync(path.dirname(p), { recursive: true });
64
77
  fs.writeFileSync(p, JSON.stringify(obj), "utf8");
65
78
  } catch {
66
79
  /* cache write is best-effort */
@@ -131,52 +144,82 @@ export function cosine(a, b) {
131
144
  /* ------------------------------------------------------------------ */
132
145
 
133
146
  /**
134
- * Build a semantic index over docs. Doc embeddings come from the on-disk
135
- * cache when the fingerprint matches; otherwise they are computed once and
136
- * persisted. Never blocks forever: total batch time is bounded and a failure
137
- * returns an index whose vectors are simply empty (lexical leg still works).
147
+ * Build a semantic index over docs with content-fingerprint incremental diff
148
+ * (v0.2.2). Doc embeddings come from the on-disk cache when the per-doc
149
+ * fingerprint (sha1 of id+text) matches; otherwise they are recomputed and
150
+ * the cache is patched incrementally — tools that didn't change are never
151
+ * re-embedded (the old code rebuilt the whole index on any set change).
152
+ *
153
+ * Any failure returns an index whose vectors are simply empty (lexical leg
154
+ * still works). Short-circuits on an overall deadline and only persists the
155
+ * cache on full success, so a half-built/offline run can never corrupt the
156
+ * prior cache.
138
157
  *
139
158
  * @param {Array<{id: string, text: string}>} docs
140
159
  * @param {number} [timeoutMs] per-embed timeout
160
+ * @param {{embed?: Function, cacheFile?: string}} [opts]
161
+ * - embed: injectable embedding fn(text, timeoutMs)->vector|null (tests);
162
+ * defaults to embedText (real Ollama).
163
+ * - cacheFile: explicit cache path (tests use a temp file; never touch the
164
+ * real ~/.dsh/state unless omitted).
141
165
  * @returns {Promise<{vectors: Map<string, number[]>, available: boolean}>}
142
166
  */
143
- export async function buildSemanticIndex(docs, timeoutMs = EMBED_TIMEOUT_MS) {
167
+ export async function buildSemanticIndex(docs, timeoutMs = EMBED_TIMEOUT_MS, opts = {}) {
144
168
  const out = { vectors: new Map(), available: false };
145
169
  const list = Array.isArray(docs) ? docs.filter((d) => d && d.id && d.text) : [];
146
170
  if (list.length === 0) return out;
147
171
 
148
- // Fast path: cache hit.
149
- const fp = hash(list.map((d) => d.id + "\u0000" + d.text).join("\u0001"));
150
- const cache = readCacheFile();
151
- const cached = cache && cache.fp === fp && cache.vec && typeof cache.vec === "object" ? cache.vec : null;
152
- if (cached) {
153
- for (const d of list) {
154
- const v = cached[d.id];
155
- if (Array.isArray(v) && v.length > 0) out.vectors.set(d.id, v);
156
- }
157
- if (out.vectors.size === list.length) {
158
- out.available = true;
159
- return out;
160
- }
161
- }
172
+ const embed = typeof opts.embed === "function" ? opts.embed : embedText;
173
+ const cachePath = opts.cacheFile || defaultCachePath();
174
+
175
+ // Per-doc content fingerprint: sha1 of id+text (name+description). A doc
176
+ // whose text changed gets a new fp → only that doc is re-embedded.
177
+ const fpOf = (d) => sha1(d.id + "\u0000" + d.text);
178
+
179
+ const cache = readCacheFileAt(cachePath);
180
+ // Liveness/lock: cache is only reusable if the provider/model that produced
181
+ // it equals the currently configured ones. Any mismatch (or old whole-index
182
+ // format, which has no .model) → full invalidation reconstruction.
183
+ const modelMatch =
184
+ cache && cache.model &&
185
+ cache.model.model === _model && cache.model.base === _base &&
186
+ cache.docs && typeof cache.docs === "object";
187
+ const cachedDocs = modelMatch ? cache.docs : null;
162
188
 
163
- // Miss: embed each doc (sequential — CPU-bound local model, avoids
164
- // overloading Ollama), bounded by an overall deadline.
189
+ // Embed every doc whose fingerprint misses the cache; reuse cached vectors.
190
+ const fresh = {}; // id -> vector, only for newly embedded
165
191
  const deadline = Date.now() + 60_000;
166
- const fresh = {};
167
- let ok = 0;
168
192
  for (const d of list) {
169
193
  if (Date.now() > deadline) break;
170
- const v = await embedText(d.text, timeoutMs);
171
- if (v) {
172
- fresh[d.id] = v;
173
- out.vectors.set(d.id, v);
174
- ok++;
194
+ let v = null;
195
+ if (cachedDocs) {
196
+ const hit = cachedDocs[fpOf(d)];
197
+ if (hit && Array.isArray(hit.vec) && hit.vec.length > 0 && hit.id === d.id) {
198
+ v = hit.vec; // unchanged → reuse cache, zero Ollama calls
199
+ }
175
200
  }
201
+ if (!v) {
202
+ v = await embed(d.text, timeoutMs);
203
+ if (v) fresh[d.id] = v;
204
+ }
205
+ if (v) out.vectors.set(d.id, v);
176
206
  }
177
- if (ok === list.length) {
207
+
208
+ if (out.vectors.size === list.length) {
178
209
  out.available = true;
179
- writeCacheFile({ fp, vec: fresh }); // persist only on full success
210
+ // Rebuild the docs map: keep reusable cached entries + newly embedded,
211
+ // dropping docs no longer in `list` (tool removal handled by set change).
212
+ const newDocs = {};
213
+ for (const d of list) {
214
+ const fp = fpOf(d);
215
+ const hit = cachedDocs && cachedDocs[fp];
216
+ if (hit && Array.isArray(hit.vec) && hit.vec.length > 0) {
217
+ newDocs[fp] = { id: d.id, vec: hit.vec };
218
+ } else if (fresh[d.id]) {
219
+ newDocs[fp] = { id: d.id, vec: fresh[d.id] };
220
+ }
221
+ }
222
+ writeCacheFileAt(cachePath, { model: { base: _base, model: _model }, docs: newDocs });
180
223
  }
181
224
  return out;
182
225
  }
package/lib/storage.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * storage.js — 插件自有配置状态的 JSON 持久化原语(v0.2.2,借鉴 Minecraftbe/dsh-toolfold)。
3
+ *
4
+ * 现状背景:插件持有 warm 集合、上次折叠统计等自有状态,但此前无处可靠落盘。
5
+ * 语义缓存已落盘 `~/.dsh/state/`,本模块提供通用的 read/write JSON 原语,
6
+ * 供 index.js 实现「三级持久化」降级:
7
+ *
8
+ * 1. `~/.dsh/settings.yaml`(或 feedbackPath 同目录)
9
+ * 2. `~/.dsh/state/`
10
+ * 3. 进程内(最后兜底)
11
+ *
12
+ * 本模块只做一件事:对单个 JSON 文件的健壮读写。任何文件系统异常都以
13
+ * 「降级返回值」处理,绝不向调用方抛错——持久化失败不等于插件崩溃。
14
+ * ⚠️ 不碰 cordis.patch.yml:那是宿主配置,插件从不写。
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ /**
21
+ * 把 data 以 JSON 落盘到 file(自动创建父目录)。
22
+ * @param {string} file 目标文件绝对路径
23
+ * @param {unknown} data 可 JSON 序列化的值
24
+ * @returns {boolean} true = 已写入;false = 任何失败(调用方降级到下一级存储)
25
+ */
26
+ export function writeState(file, data) {
27
+ if (!file) return false;
28
+ try {
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ fs.writeFileSync(file, JSON.stringify(data), "utf8");
31
+ return true;
32
+ } catch {
33
+ return false; // 目录不可写 / 权限 / 磁盘满 → 降级
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 从 file 读取 JSON;任何失败(不存在/损坏/无权限/非对象)都返回 def。
39
+ * @param {string} file
40
+ * @param {unknown} def 默认值(读失败时的兜底)
41
+ * @returns {unknown} 解析出的值(独立副本)或 def
42
+ */
43
+ export function readState(file, def) {
44
+ if (!file) return def;
45
+ try {
46
+ if (!fs.existsSync(file)) return def;
47
+ const raw = fs.readFileSync(file, "utf8");
48
+ const j = JSON.parse(raw);
49
+ if (j && typeof j === "object") return JSON.parse(JSON.stringify(j)); // 独立副本
50
+ return def;
51
+ } catch {
52
+ return def; // 损坏 JSON / 权限 → 默认值降级
53
+ }
54
+ }
package/lib/ux.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ux.js — P0 折叠体验改进(2026-08-30 第二轮调研落地,来源见 AGENTS.md §0.3)。
3
+ *
4
+ * 四个纯函数/工厂,全部不触碰注入面(每轮 prompt 字节零变化,兼容红线保持):
5
+ * ① expandQuery 中文高频意图 → 追加英文检索词(只加词不改原词;BM25 召回
6
+ * 更宽,精确名匹配 prioritizeExact 仍用原 query,不受影响)
7
+ * ② fallbackCatalog tools_search 零匹配时回退的全量轻目录(name + 80 字描述,
8
+ * 按名字典序稳定排序)——根治「搜词不对就以为没有」
9
+ * ③ createSearchCache 同 query 内容指纹的结果缓存(固定 TTL + LRU 上限)——
10
+ * 同会话重复搜索返回同一结果集(漂移的最小修)
11
+ * ④ recordDiscoveries 发现率统计(name→count,落盘可恢复)——低频工具名单
12
+ * 反哺描述改进(官方「监控发现率」建议的本地等价物)
13
+ */
14
+
15
+ /* ------------------------- ① query 扩展词表 ------------------------- */
16
+
17
+ /** 中文高频意图 → 英文检索词。只做「包含即追加」,不做分词/替换。 */
18
+ const EXPAND = {
19
+ 搜索: "search find lookup",
20
+ 查找: "search find lookup",
21
+ 文件: "file files read write edit",
22
+ 图片: "image picture photo vision ocr",
23
+ 截图: "screenshot screen capture image",
24
+ 识别: "ocr recognize extract text",
25
+ 文字: "text ocr recognize",
26
+ 数据库: "database db sql query",
27
+ 查询: "query select fetch search",
28
+ 网络: "network web http fetch url",
29
+ 浏览器: "browser chrome page navigate",
30
+ 下载: "download fetch get",
31
+ 上传: "upload put push",
32
+ 发布: "publish deploy release",
33
+ 部署: "deploy release publish",
34
+ 测试: "test run verify",
35
+ 运行: "run execute start",
36
+ 执行: "run execute command",
37
+ 命令: "command shell exec terminal",
38
+ 安装: "install setup configure",
39
+ 配置: "config configure setup",
40
+ 错误: "error fail exception debug",
41
+ 修复: "fix repair patch debug",
42
+ 删除: "delete remove rm",
43
+ 创建: "create new make init",
44
+ 修改: "edit update modify change",
45
+ 查看: "view show list inspect read",
46
+ 列表: "list enumerate show",
47
+ 记忆: "memory remember recall viking",
48
+ 技能: "skill search catalog",
49
+ 工具: "tool search schema",
50
+ 定时: "schedule cron timer",
51
+ 提醒: "remind schedule alert",
52
+ 邮件: "email mail send",
53
+ 键盘: "keyboard type input",
54
+ 鼠标: "mouse click move",
55
+ 窗口: "window open close",
56
+ 进程: "process task list kill",
57
+ 服务: "service server daemon",
58
+ 日志: "log trace tail",
59
+ 监控: "monitor watch observe",
60
+ 版本: "version release tag git",
61
+ 提交: "commit push git",
62
+ 分支: "branch git merge",
63
+ 仓库: "repo repository git",
64
+ 会话: "session agent",
65
+ 对话: "chat session message",
66
+ };
67
+
68
+ /**
69
+ * 中文意图 → 扩展查询串:命中的每个中文词追加其英文同义词组到尾部。
70
+ * 无中文(或词表未命中)时原样返回——旧行为零变化。
71
+ * @param {string} q
72
+ * @returns {string}
73
+ */
74
+ export function expandQuery(q) {
75
+ const s = String(q || "").trim();
76
+ if (!s) return s;
77
+ const extra = [];
78
+ for (const [cn, en] of Object.entries(EXPAND)) {
79
+ if (s.includes(cn)) extra.push(en);
80
+ }
81
+ return extra.length ? `${s} ${extra.join(" ")}` : s;
82
+ }
83
+
84
+ /* ------------------------- ② 零匹配回退轻目录 ------------------------- */
85
+
86
+ /**
87
+ * 轻目录:按名字典序排序取前 limit 个(name + 描述截 80 + server)。
88
+ * limit<=0 → [](关闭回退,行为与旧版一致)。
89
+ * @param {Array<{name:string,description?:string}>} allTools
90
+ * @param {number} limit
91
+ * @param {(name:string)=>string} [serverOf]
92
+ * @returns {Array<{name:string,description:string,server:string}>}
93
+ */
94
+ export function fallbackCatalog(allTools, limit, serverOf) {
95
+ const lim = Math.max(0, Number(limit) || 0);
96
+ if (!lim || !Array.isArray(allTools) || allTools.length === 0) return [];
97
+ const svc = typeof serverOf === "function" ? serverOf : () => "";
98
+ return [...allTools]
99
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)))
100
+ .slice(0, lim)
101
+ .map((t) => ({
102
+ name: String(t.name || ""),
103
+ description: String(t.description || "").slice(0, 80),
104
+ server: svc(t.name),
105
+ }));
106
+ }
107
+
108
+ /* ------------------------- ③ 检索结果缓存 ------------------------- */
109
+
110
+ /**
111
+ * 结果缓存工厂:同 key 在 TTL 内返回同一结果(确定性),超 TTL 或超上限逐出。
112
+ * @param {number} [maxSize] LRU 上限(默认 200)
113
+ * @param {number} [ttlMs] 有效期(默认 60s)
114
+ */
115
+ export function createSearchCache(maxSize = 200, ttlMs = 60000) {
116
+ const map = new Map();
117
+ return {
118
+ get(key) {
119
+ const e = map.get(key);
120
+ if (!e) return null;
121
+ if (Date.now() - e.ts > ttlMs) {
122
+ map.delete(key);
123
+ return null;
124
+ }
125
+ return e.value;
126
+ },
127
+ set(key, value) {
128
+ map.set(key, { ts: Date.now(), value });
129
+ if (map.size > maxSize) {
130
+ const oldest = map.keys().next().value;
131
+ if (oldest !== undefined) map.delete(oldest);
132
+ }
133
+ },
134
+ size() {
135
+ return map.size;
136
+ },
137
+ };
138
+ }
139
+
140
+ /* ------------------------- ④ 发现率统计 ------------------------- */
141
+
142
+ /**
143
+ * 记录一次发现的工具名(name→count)。
144
+ * @param {Map<string,number>} stats
145
+ * @param {Array<string>} names
146
+ * @returns {number} 当前去重工具数
147
+ */
148
+ export function recordDiscoveries(stats, names) {
149
+ const list = Array.isArray(names) ? names.filter(Boolean) : [];
150
+ for (const n of list) stats.set(n, (stats.get(n) || 0) + 1);
151
+ return stats.size;
152
+ }
153
+
154
+ /**
155
+ * 序列化为按 count 降序的数组(cap 截断),供落盘。
156
+ * @param {Map<string,number>} stats
157
+ * @param {number} [cap]
158
+ */
159
+ export function serializeDiscoveries(stats, cap = 200) {
160
+ return [...stats.entries()]
161
+ .sort((a, b) => b[1] - a[1])
162
+ .slice(0, Math.max(1, cap))
163
+ .map(([name, count]) => ({ name, count }));
164
+ }
165
+
166
+ /**
167
+ * 从落盘数组恢复 Map;坏条目跳过(fail-safe)。
168
+ * @param {Array<{name:string,count:number}>} [list]
169
+ */
170
+ export function deserializeDiscoveries(list) {
171
+ const m = new Map();
172
+ for (const e of list || []) {
173
+ if (e && typeof e.name === "string" && e.name.length > 0 && Number.isFinite(e.count) && e.count > 0) {
174
+ m.set(e.name, e.count);
175
+ }
176
+ }
177
+ return m;
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tool-folder",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Fold the DSH tool surface per request + ChainGuard firewall (high-risk block + exfil-chain detection + anti-obfuscation) + BM25/bge-m3 hybrid tools_search. Shrinks schema tokens 80-90% while keeping selection accuracy. v0.2.0 adds a semantic retrieval leg (local Ollama bge-m3, RRF hybrid) and ChainGuard obfuscation detection (concat-rebuild / -enc / certutil / bitsadmin / homoglyphs).",
5
5
  "keywords": [
6
6
  "dsh-plugin",