dsh-tool-folder 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,6 +149,37 @@ were never unregistered, so nothing else changes.
149
149
 
150
150
  ## Changelog
151
151
 
152
+ ### v0.2.2 — tiered disclosure budget + warm LRU + incremental semantic cache (2026-08-30)
153
+ 1. **Tiered disclosure budget** (`disclosureBudget`, default 0 = off): caps how
154
+ many tool names + descriptions are disclosed per request. Over budget, tools
155
+ are demoted tier by tier (T2 name+desc → T3 name-only → T4 hidden) instead
156
+ of being dropped wholesale; core/hot/meta tools stay tier-1 and never demote.
157
+ 2. **Warm LRU** (`maxWarmTools`, default 0 = off): recently used tools are kept
158
+ visible within a bounded LRU, persisted to state so a restart keeps them
159
+ warm. `0` keeps the exact legacy injection surface.
160
+ 3. **Incremental semantic cache**: the bge-m3 embedding cache now diffs by
161
+ content fingerprint — only docs whose text changed get re-embedded, and a
162
+ model change rebuilds once. A failed refresh never clobbers a good cache.
163
+ 4. **Config persistence**: `lib/storage.js` reads/writes state with a
164
+ settings → state → memory fallback chain (never touches cordis.patch.yml).
165
+ 5. **Explicit fail-open**: `missingMetaTools` gates meta-tool availability —
166
+ any snapshot oddity returns the full tool list rather than a folded one.
167
+ 6. 72 tests green (new: budget 8 / warm 8 / semantic-incremental 5 / storage 7
168
+ / fail-open 4).
169
+
170
+ ### v0.2.1 — heat decay + exact-name priority (2026-08-30)
171
+ 1. **Hot heat decay** (`hotWindowDays`, default 3): a tool is promoted to
172
+ always-visible only when its call count stays inside the sliding window —
173
+ the old code promoted forever, so any tool used 3 times was permanently
174
+ resident and the fold silently decayed to zero over time. Legacy
175
+ `feedback.json` number entries are read as out-of-window → their stale
176
+ heat expires naturally. Set `hotWindowDays: 0` to keep the old behavior.
177
+ 2. **Exact-name priority in `tools_search`**: exact tool-name match always
178
+ ranks first, a ≥4-char query that is a name substring is pulled ahead of
179
+ BM25/semantic hits (deterministic fallback chain). Results stay
180
+ deduplicated.
181
+ 3. 40 tests green (11 new: window decay / legacy compat / priority order).
182
+
152
183
  ### v0.2.0 — first npm release (2026-08-30)
153
184
  This is the first version published to npm. It bundles the v0.1.7 firewall
154
185
  scope fix (exec-only hard block) and the v0.1.8 semantic leg +
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,9 @@ 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 } 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";
37
39
  import { ChainGuard, verdict } from "./chainguard.js";
38
40
  import { matchCategory } from "./category.js";
39
41
  import { toonifyValue } from "./toonify.js";
@@ -59,6 +61,8 @@ export const Config = z.object({
59
61
  deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
60
62
  topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
61
63
  hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
64
+ hotWindowDays: z.number().min(0).max(365).default(3)
65
+ .description("hot 热度的滑动窗口(天):窗口内达标才常驻,超窗自动衰减;0=永不过期"),
62
66
  catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
63
67
  compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
64
68
  compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
@@ -75,6 +79,10 @@ export const Config = z.object({
75
79
  semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
76
80
  ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
77
81
  embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
82
+ disclosureBudget: z.number().min(0).max(200000).default(0)
83
+ .description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
84
+ maxWarmTools: z.number().min(0).max(50).default(0)
85
+ .description("warm LRU 上限:tools_search/schema 发现或折叠后被调用的工具进入 warm 集合,下一轮注入(完整 schema);达上限逐出最久未用;0=关闭(默认关,无配置升级逐字节等价,显式开启才启用)"),
78
86
  });
79
87
 
80
88
  const DEFAULTS = {
@@ -84,6 +92,7 @@ const DEFAULTS = {
84
92
  deny: [], // never inject + refuse execution (exact name or "prefix*")
85
93
  topK: 6,
86
94
  hotThreshold: 3,
95
+ hotWindowDays: 3, // v0.2.1: sliding-window heat decay (0 = never expire)
87
96
  catalogEnabled: true, // safety net: folded tools stay discoverable by name
88
97
  compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
89
98
  compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
@@ -108,6 +117,8 @@ const DEFAULTS = {
108
117
  semanticEnabled: true, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
109
118
  ollamaBase: "http://127.0.0.1:11434",
110
119
  embedModel: "bge-m3",
120
+ disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
121
+ maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
111
122
  };
112
123
 
113
124
  function feedbackPath(cfg) {
@@ -279,6 +290,56 @@ export function apply(ctx, config) {
279
290
  const disposers = [];
280
291
  const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
281
292
 
293
+ /* ------------------------------------------------------------------ */
294
+ /* v0.2.2: warm LRU + 配置三级持久化 */
295
+ /* ------------------------------------------------------------------ */
296
+ // warm:Map<name, {last}>,LRU 顺序由 last 单调递增序号决定。被
297
+ // tools_search 搜到 / tools_schema 展开 / 折叠后仍被调用的工具进入集合,
298
+ // 下一轮注入完整 schema。maxWarmTools=0 时整个 warm 失效(仍不 touch)。
299
+ const warm = new Map();
300
+ const warmEnabled = cfg.maxWarmTools > 0;
301
+ const touchWarmNames = (names) => {
302
+ if (!warmEnabled || !Array.isArray(names) || names.length === 0) return;
303
+ touchWarm(warm, names, Date.now(), cfg.maxWarmTools);
304
+ persistWarmState();
305
+ };
306
+
307
+ // 三级持久化:① feedbackPath 同目录(首选)→ ② ~/.dsh/state/ → ③ 进程内。
308
+ // ⚠️ 绝不写 cordis.patch.yml(宿主配置,插件只读宿主、只落自有 state 文件)。
309
+ const primaryStateFile = () => path.join(path.dirname(fbPath), "tool-folder-state.json");
310
+ const fallbackStateFile = () => {
311
+ const home = process.env.DSH_HOME || process.env.USERPROFILE || process.env.HOME || "";
312
+ return home ? `${home}/.dsh/state/tool-folder-state.json` : "tool-folder-state.json";
313
+ };
314
+ let activeStateFile = primaryStateFile();
315
+ const restoreWarm = () => {
316
+ if (!warmEnabled) return; // 关闭态绝不载入磁盘残留(Spec 2: maxWarmTools=0=彻底关闭)
317
+ let cloud = readState(activeStateFile, null);
318
+ if (!cloud) {
319
+ activeStateFile = fallbackStateFile();
320
+ cloud = readState(activeStateFile, null);
321
+ }
322
+ if (cloud && Array.isArray(cloud.warm)) {
323
+ for (const [name, meta] of cloud.warm) {
324
+ if (typeof name === "string" && meta && typeof meta.last === "number") {
325
+ warm.set(name, { last: meta.last });
326
+ }
327
+ }
328
+ evictWarm(warm, cfg.maxWarmTools); // 载入即按上限裁剪
329
+ }
330
+ };
331
+ const persistWarmState = () => {
332
+ if (!warmEnabled) return;
333
+ const payload = { warm: [...warm.entries()], ts: Date.now() };
334
+ let ok = writeState(activeStateFile, payload);
335
+ if (!ok) {
336
+ // 首选目录不可写 → 降到 ~/.dsh/state/
337
+ activeStateFile = fallbackStateFile();
338
+ writeState(activeStateFile, payload);
339
+ }
340
+ };
341
+ restoreWarm();
342
+
282
343
  // "mcp__viking__remember" → "mcp__viking"; plain tools ("tool-bash") stay
283
344
  // as themselves (single segment survives slice(0,2)).
284
345
  const serverOf = (name) => String(name).split("__").slice(0, 2).join("__");
@@ -403,7 +464,13 @@ export function apply(ctx, config) {
403
464
  const q = String(args.query || "");
404
465
  const lim = args.limit ?? 8;
405
466
  if (!index || !q) return { query: q, results: [], total: 0 };
406
- const bm25Hits = search(index, q, lim * 2).map((i) => ({ id: allTools[i].name }));
467
+ // v0.2.1: exact-name > name-substring > BM25 priority (deterministic
468
+ // fallback chain, borrowed from Hermes-style tool search).
469
+ const bm25Hits = prioritizeExact(
470
+ allTools,
471
+ q,
472
+ search(index, q, lim * 2).map((i) => ({ id: allTools[i].name })),
473
+ );
407
474
  let fused = bm25Hits;
408
475
  // Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
409
476
  // Any failure (Ollama down / timeout / cache miss on error) degrades
@@ -421,16 +488,19 @@ export function apply(ctx, config) {
421
488
  }
422
489
  }
423
490
  const byName = new Map(allTools.map((t) => [t.name, t]));
491
+ const results = fused.map((h) => {
492
+ const t = byName.get(h.id) || {};
493
+ return {
494
+ name: h.id,
495
+ description: (t.description || "").slice(0, 160),
496
+ server: serverOf(h.id),
497
+ };
498
+ });
499
+ // v0.2.2: 被搜到的工具名进入 warm 集合,下一轮注入完整 schema。
500
+ touchWarmNames(results.map((r) => r.name));
424
501
  return {
425
502
  query: q,
426
- results: fused.map((h) => {
427
- const t = byName.get(h.id) || {};
428
- return {
429
- name: h.id,
430
- description: (t.description || "").slice(0, 160),
431
- server: serverOf(h.id),
432
- };
433
- }),
503
+ results,
434
504
  total: fused.length,
435
505
  };
436
506
  },
@@ -475,6 +545,8 @@ export function apply(ctx, config) {
475
545
  const name = String(args?.name || "");
476
546
  const t = allTools.find((x) => x.name === name);
477
547
  if (!t) return { name, found: false, reason: "unknown tool" }; // never throw
548
+ // v0.2.2: 展开过 schema 的工具名进入 warm 集合,下一轮保持注入。
549
+ touchWarmNames([name]);
478
550
  return {
479
551
  name: t.name,
480
552
  found: true,
@@ -519,10 +591,12 @@ export function apply(ctx, config) {
519
591
 
520
592
  const agentId = context?.agent?.id || "default";
521
593
  const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
594
+ // v0.2.1: hot = sliding-window decay (hotWindowDays). Tools not called
595
+ // inside the window lose their historical heat — the old code promoted
596
+ // forever, which slowly nullified the fold.
522
597
  const hotNames = new Set(
523
- Object.entries(feedback.calls || {})
524
- .filter(([, c]) => c >= cfg.hotThreshold)
525
- .map(([n]) => n),
598
+ Object.keys(feedback.calls || {}).filter((n) =>
599
+ isHotTool(feedback.calls, n, cfg.hotThreshold, cfg.hotWindowDays * 86400000)),
526
600
  );
527
601
 
528
602
  const stamp = tools.map((t) => t.name).join("\u0001");
@@ -553,6 +627,11 @@ export function apply(ctx, config) {
553
627
  const query = extractQuery(context);
554
628
  const dynNames = buildDynamicSet(tools, query);
555
629
 
630
+ // v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
631
+ const warmNamesSet = new Set(
632
+ [...warm.keys()].filter((n) => tools.some((t) => t.name === n)),
633
+ );
634
+
556
635
  // L5: stable ordering — core by config order, hot by name, dyn by name.
557
636
  // A deterministic byte order keeps the prompt prefix cacheable.
558
637
  const coreTools = tools.filter((t) => coreNames.has(t.name));
@@ -564,12 +643,24 @@ export function apply(ctx, config) {
564
643
  (t) =>
565
644
  !coreNames.has(t.name) &&
566
645
  !hotNames.has(t.name) &&
646
+ !warmNamesSet.has(t.name) &&
567
647
  dynNames.has(t.name),
568
648
  )
569
649
  .sort((a, b) => (a.name < b.name ? -1 : 1));
570
650
 
651
+ // v0.2.2: warm = 上一轮被 tools_search/schema 发现或折叠后被调用的工具。
652
+ // 属主动晋升(接近 hot),完整 schema 注入,不作为 budget 可降级段。
653
+ const warmTools = tools
654
+ .filter(
655
+ (t) =>
656
+ !coreNames.has(t.name) &&
657
+ !hotNames.has(t.name) &&
658
+ warmNamesSet.has(t.name),
659
+ )
660
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
661
+
571
662
  // L1: schema compression + description normalization on the dynamic
572
- // segment only. core/hot never pass through this pipe (P0 red line).
663
+ // segment only. core/hot/warm never pass through this pipe (P0 red line).
573
664
  const trimmed =
574
665
  effectiveLevel !== "off" || cfg.normalizeDescriptions
575
666
  ? dynTools.map((t) => {
@@ -583,7 +674,40 @@ export function apply(ctx, config) {
583
674
  })
584
675
  : dynTools;
585
676
 
586
- const visible = [...coreTools, ...hotTools, ...trimmed];
677
+ // v0.2.2 分级披露预算:动态段(trimmed)是唯一可降级段。core/hot/warm
678
+ // 作为 protected 恒层级 1。tierForBudget 返回每个可见名字的降级层级;
679
+ // 层级 2/3 组装压缩形态,层级 4 不再注入(由 catalog 兜底发现)。
680
+ let visible = [...coreTools, ...hotTools, ...warmTools, ...trimmed];
681
+ if (cfg.disclosureBudget > 0) {
682
+ const protectedNames = new Set([
683
+ ...coreNames,
684
+ ...hotNames,
685
+ ...warmNamesSet,
686
+ // meta 工具永远完整可见
687
+ "tools_search",
688
+ "tools_schema",
689
+ ]);
690
+ const tiers = tierForBudget(visible, cfg.disclosureBudget, protectedNames);
691
+ // Effective budget = min(disclosureBudget, context estimate). We do not
692
+ // separately measure context here, so the hard disclosureBudget stands.
693
+ // Rebuild visible honoring per-tool tiers (they are already ordered).
694
+ visible = visible.map((t) => {
695
+ const tier = tiers.byName[t.name] || 1;
696
+ if (tier === 1) return t; // 完整 schema
697
+ if (tier === 2) {
698
+ // name + description(描述压缩至 100 字符)
699
+ return {
700
+ name: t.name,
701
+ description: String(t.description || "").slice(0, 100),
702
+ };
703
+ }
704
+ if (tier === 3) {
705
+ // 仅 name(一行一个)
706
+ return { name: t.name };
707
+ }
708
+ return null; // 层级 4:折叠进 catalog,不注入
709
+ }).filter(Boolean);
710
+ }
587
711
 
588
712
  // The meta-tools must always stay visible or the model can never ask
589
713
  // for folded tools (they would be hidden by their own fold).
@@ -598,6 +722,21 @@ export function apply(ctx, config) {
598
722
  }
599
723
  }
600
724
 
725
+ // v0.2.2 fail-open 硬保证(借鉴 fan56/dsh-mcp-adapter):启用的发现
726
+ // 元工具仍缺失(注册失败/名冲突/部分启动)→ 折叠后工具不可发现,
727
+ // 比不折叠更糟——显式回退官方全量工具列表,绝不静默吞掉发现路径。
728
+ const missingMeta = missingMetaTools(
729
+ new Set(visible.map((t) => t.name)),
730
+ { tools_search: cfg.toolSearchEnabled, tools_schema: cfg.schemaToolEnabled },
731
+ );
732
+ if (missingMeta.length > 0) {
733
+ logger.warn(
734
+ "fail-open: meta tool(s) %s missing after fold — falling back to full tool list",
735
+ missingMeta.join(","),
736
+ );
737
+ return next();
738
+ }
739
+
601
740
  const visibleNames = visible.map((t) => t.name);
602
741
  lastVisible = { agentId, names: visibleNames, ts: Date.now() };
603
742
 
@@ -608,6 +747,7 @@ export function apply(ctx, config) {
608
747
  !isMetaTool(t.name) &&
609
748
  !coreNames.has(t.name) &&
610
749
  !hotNames.has(t.name) &&
750
+ !warmNamesSet.has(t.name) &&
611
751
  !dynNames.has(t.name),
612
752
  );
613
753
  if (folded.length > 0) {
@@ -673,8 +813,14 @@ export function apply(ctx, config) {
673
813
  });
674
814
  }
675
815
 
676
- // Heat counting (L4 feedback).
677
- feedback.calls[name] = (feedback.calls[name] || 0) + 1;
816
+ // Heat counting (L4 feedback). v0.2.1: upgrade to { n, ts } so the
817
+ // hot gate can decay stale heat; legacy number entries are kept
818
+ // readable by isHotTool (ts=0 → outside any positive window).
819
+ const prev = feedback.calls[name];
820
+ feedback.calls[name] = {
821
+ n: (typeof prev === "number" ? prev : prev?.n || 0) + 1,
822
+ ts: Date.now(),
823
+ };
678
824
 
679
825
  // Firewall: record the call sequence and flag exfil chains.
680
826
  if (cfg.firewallEnabled !== false) {
@@ -700,6 +846,8 @@ export function apply(ctx, config) {
700
846
  "folded tools called (coverage=%.2f): %s — promoting via heat",
701
847
  cov.coverage, cov.uncovered.join(", "),
702
848
  );
849
+ // v0.2.2: 折叠后仍被调用的工具名也进 warm,下一轮避免再折叠。
850
+ touchWarmNames(cov.uncovered);
703
851
  }
704
852
 
705
853
  // L3: record the last 50 sessions for offline evaluation.
package/lib/metrics.js CHANGED
@@ -59,3 +59,149 @@ export function selectionCoverage(visibleNames, calledNames) {
59
59
  uncovered,
60
60
  };
61
61
  }
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
+
81
+ /**
82
+ * Hot-promotion gate with sliding-window decay (v0.2.1).
83
+ * feedback.calls entries may be:
84
+ * - new shape { n, ts } — n calls, ts = last call epoch ms;
85
+ * - legacy shape (number) — pre-v0.2.1 counts with no timestamp.
86
+ * A tool is hot when n >= hotThreshold AND (windowMs <= 0 OR the last call
87
+ * is inside the window). Legacy numbers carry ts=0 → outside any positive
88
+ * window → not hot (a tool that hasn't been called since the upgrade
89
+ * naturally loses its historical heat — this is the decay the old code
90
+ * lacked). windowMs=0 keeps the legacy forever-hot behavior for opt-out.
91
+ * @param {object|null} calls - feedback.calls map.
92
+ * @param {string} name - tool name.
93
+ * @param {number} hotThreshold - min calls to be hot.
94
+ * @param {number} windowMs - decay window; <=0 disables time checks.
95
+ * @returns {boolean}
96
+ */
97
+ export function isHotTool(calls, name, hotThreshold, windowMs) {
98
+ if (!calls || typeof calls !== "object") return false;
99
+ const v = calls[name];
100
+ if (v == null) return false;
101
+ const n = typeof v === "number" ? v : v.n;
102
+ const ts = typeof v === "number" ? 0 : v.ts;
103
+ if (!(n >= hotThreshold)) return false;
104
+ if (windowMs > 0 && !(ts > 0 && Date.now() - ts <= windowMs)) return false;
105
+ return true;
106
+ }
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
+
171
+ /**
172
+ * Deterministic retrieval priority (v0.2.1, borrowed from Hermes-style
173
+ * "exact name > name token > description" fallback chains): the query's
174
+ * exact tool-name match always ranks first; a ≥4-char query that is a
175
+ * substring of a tool name is pulled ahead of BM25/semantic hits. Everything
176
+ * else keeps the given hit order, deduplicated.
177
+ * @param {Array<{name: string}>} docs - the tool corpus (allTools).
178
+ * @param {string} q - the search query.
179
+ * @param {Array<{id: string, score: number}>} hits - ranked hits.
180
+ * @returns {Array<{id: string, score: number}>} re-ordered hits.
181
+ */
182
+ export function prioritizeExact(docs, q, hits) {
183
+ const query = String(q || "").trim().toLowerCase();
184
+ if (!query || !Array.isArray(docs) || !Array.isArray(hits)) return hits;
185
+ const ordered = [];
186
+ const seen = new Set();
187
+ const exact = docs.find((d) => String(d.name || "").toLowerCase() === query);
188
+ if (exact) {
189
+ ordered.push({ id: exact.name, score: 0 });
190
+ seen.add(exact.name);
191
+ }
192
+ if (query.length >= 4) {
193
+ for (const d of docs) {
194
+ if (seen.has(d.name)) continue;
195
+ if (String(d.name || "").toLowerCase().includes(query)) {
196
+ ordered.push({ id: d.name, score: 0 });
197
+ seen.add(d.name);
198
+ }
199
+ }
200
+ }
201
+ for (const h of hits) {
202
+ if (!h || seen.has(h.id)) continue;
203
+ ordered.push(h);
204
+ seen.add(h.id);
205
+ }
206
+ return ordered;
207
+ }
@@ -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/package.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "name": "dsh-tool-folder",
3
- "version": "0.2.0",
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
- "keywords": [
6
- "dsh-plugin",
7
- "dsh",
8
- "deepseek-harness",
9
- "cordis",
10
- "tool-selection",
11
- "token-optimization",
12
- "bm25",
13
- "semantic-search",
14
- "security",
15
- "firewall",
16
- "obfuscation"
17
- ],
18
- "type": "module",
19
- "main": "lib/index.js",
20
- "files": [
21
- "lib",
22
- "cordis.patch.yml",
23
- "README.md"
24
- ],
25
- "dsh": {
26
- "bundle": {
27
- "patch": "./cordis.patch.yml"
28
- }
29
- },
30
- "license": "MIT",
31
- "engines": {
32
- "node": ">=18"
33
- },
34
- "dependencies": {
35
- "@deepseek-ai/schemastery": "^3.18.1"
36
- },
37
- "peerDependencies": {
38
- "@deepseek-ai/cordis": "^4.0.1",
39
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
40
- },
41
- "scripts": {
42
- "test": "node --test \"test/*.test.js\""
43
- },
44
- "exports": {
45
- ".": "./lib/index.js",
46
- "./lib/*": "./lib/*"
47
- }
1
+ {
2
+ "name": "dsh-tool-folder",
3
+ "version": "0.2.2",
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
+ "keywords": [
6
+ "dsh-plugin",
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "cordis",
10
+ "tool-selection",
11
+ "token-optimization",
12
+ "bm25",
13
+ "semantic-search",
14
+ "security",
15
+ "firewall",
16
+ "obfuscation"
17
+ ],
18
+ "type": "module",
19
+ "main": "lib/index.js",
20
+ "files": [
21
+ "lib",
22
+ "cordis.patch.yml",
23
+ "README.md"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ }
29
+ },
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": "\u003e=18"
33
+ },
34
+ "dependencies": {
35
+ "@deepseek-ai/schemastery": "^3.18.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/cordis": "^4.0.1",
39
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test \"test/*.test.js\""
43
+ },
44
+ "exports": {
45
+ ".": "./lib/index.js",
46
+ "./lib/*": "./lib/*"
47
+ }
48
48
  }