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 +31 -0
- package/lib/budget.js +94 -0
- package/lib/index.js +165 -17
- package/lib/metrics.js +146 -0
- package/lib/obfuscation.js +0 -11
- package/lib/semantic.js +79 -36
- package/lib/storage.js +54 -0
- package/package.json +47 -47
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
|
-
|
|
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
|
|
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.
|
|
524
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/obfuscation.js
CHANGED
|
@@ -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
|
|
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
|
|
73
|
+
function writeCacheFileAt(p, obj) {
|
|
60
74
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
164
|
-
//
|
|
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
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
207
|
+
|
|
208
|
+
if (out.vectors.size === list.length) {
|
|
178
209
|
out.available = true;
|
|
179
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|