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 +43 -0
- package/lib/budget.js +94 -0
- package/lib/index.js +221 -18
- package/lib/metrics.js +81 -0
- package/lib/obfuscation.js +0 -11
- package/lib/semantic.js +79 -36
- package/lib/storage.js +54 -0
- package/lib/ux.js +178 -0
- package/package.json +1 -1
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
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/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.
|
|
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",
|