dsh-tool-folder 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/semantic.js CHANGED
@@ -1,31 +1,34 @@
1
1
  /**
2
- * semantic.js — local semantic retrieval leg via Ollama bge-m3 (2026-08-30).
2
+ * semantic.js — 语义检索腿(远程优先 → 本地 Ollama → 纯 BM25 降级)。
3
3
  *
4
- * Complements the BM25 lexical leg (bm25.js) with a cross-lingual semantic
5
- * leg: Chinese intents can hit English-only tool/skill descriptions that
6
- * share no surface tokens (the documented BM25 gap). Uses the machine's
7
- * local Ollama (`http://127.0.0.1:11434/api/embed`, model bge-m3 — 1024 dims,
8
- * ~1.2GB, already installed per ~/.dsh config). No npm deps, no network egress.
4
+ * 历史:v0.1.8 只走本地 Ollama bge-m3;v0.3.0 起改为**远程优先**。
5
+ * 原因(2026-10-17 实测):本机没装 Ollama,`127.0.0.1:11434` 返回 000,
6
+ * 语义腿恒为 null,`tools_search` 实际只剩 BM25,中英跨语言命中率极低。
9
7
  *
10
- * Hard-fail design: every external path (fetch, fs cache) is optional. If
11
- * Ollama is down, the cache is unreadable, or anything throws, callers get
12
- * null/[] and their existing BM25 path just keeps working.
8
+ * 结构:
9
+ * embedText 单条:远程 → Ollama → null
10
+ * embedBatch 批量:远程批量(4 条/批、2 并发、429 退避)→ 逐条降级
11
+ * buildSemanticIndex 增量索引(逐 doc sha1 指纹 + 磁盘缓存,部分成功也落盘)
12
+ * searchSemantic 查询向量(带内存缓存)→ cosine 排序
13
+ * rrfFuse 加权 RRF(语义权重可调,默认 3.0)
13
14
  *
14
- * Cache: document embeddings are keyed by text and persisted to
15
- * `~/.dsh/state/semantic-cache.json` (fingerprint = hash of all doc texts), so
16
- * the ~10s cold start for 300+ tools happens once, not per session.
17
- * Query embeddings are cached in memory only (per-process, tiny).
15
+ * 硬失败设计:任何外部路径(fetch / fs / 缓存)失败都只会让调用方回退到 BM25。
18
16
  */
19
17
 
20
- const OLLAMA_BASE = "http://127.0.0.1:11434";
21
- const DEFAULT_MODEL = "bge-m3";
22
- const EMBED_TIMEOUT_MS = 8000;
23
- const CACHE_FILE = ".dsh/state/semantic-cache.json";
24
-
25
18
  import fs from "node:fs";
26
19
  import path from "node:path";
27
20
  import crypto from "node:crypto";
28
21
 
22
+ import { remoteEmbed, remoteEmbedBatch, remoteEmbedAvailable, configureRemoteEmbed } from "./embed-remote.js";
23
+
24
+ const OLLAMA_BASE = "http://127.0.0.1:11434";
25
+ const DEFAULT_MODEL = "bge-m3";
26
+ const EMBED_TIMEOUT_MS = 12000;
27
+ const CACHE_FILE = "state/semantic-cache.json";
28
+ const INDEX_DEADLINE_MS = 180_000; // 243 个工具 × 2048 维,4 条/批 ≈ 25-40s
29
+ const QUERY_CACHE_MAX = 64;
30
+ const QUERY_TTL_MS = 10 * 60 * 1000;
31
+
29
32
  /* ------------------------------------------------------------------ */
30
33
  /* hash + fs helpers (all fail-safe) */
31
34
  /* ------------------------------------------------------------------ */
@@ -46,7 +49,7 @@ function sha1(text) {
46
49
  try {
47
50
  return crypto.createHash("sha1").update(String(text || ""), "utf8").digest("hex");
48
51
  } catch {
49
- return hash(text); // node:crypto 不可用时的降级指纹
52
+ return hash(text);
50
53
  }
51
54
  }
52
55
 
@@ -54,8 +57,22 @@ function homeDir() {
54
57
  return process.env.USERPROFILE || process.env.HOME || "";
55
58
  }
56
59
 
60
+ /**
61
+ * 缓存路径:优先 `$DSH_HOME/state/`,再退 `~/.dsh-community`、`~/.dsh`。
62
+ * v0.3.0 修正:旧实现只认 USERPROFILE,DSH_HOME 指向 .dsh-community 时
63
+ * 缓存会落到另一个目录(和 dsh-recall 早期踩的是同一个坑)。
64
+ */
57
65
  function defaultCachePath() {
58
- return homeDir() + "/" + CACHE_FILE;
66
+ const roots = [];
67
+ if (process.env.DSH_HOME) roots.push(process.env.DSH_HOME);
68
+ const community = path.join(homeDir(), ".dsh-community");
69
+ try {
70
+ if (fs.existsSync(community)) roots.push(community);
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ roots.push(path.join(homeDir(), ".dsh"));
75
+ return path.join(roots[0] || homeDir(), CACHE_FILE);
59
76
  }
60
77
 
61
78
  function readCacheFileAt(p) {
@@ -81,25 +98,45 @@ function writeCacheFileAt(p, obj) {
81
98
  }
82
99
 
83
100
  /* ------------------------------------------------------------------ */
84
- /* Ollama embedding (fail-safe fetch) */
101
+ /* config + provider identity */
85
102
  /* ------------------------------------------------------------------ */
86
103
 
87
104
  let _model = DEFAULT_MODEL;
88
105
  let _base = OLLAMA_BASE;
106
+ let _timeout = EMBED_TIMEOUT_MS;
89
107
 
90
108
  /**
91
- * Configure the endpoint (called by plugin apply with cfg.ollamaBase/model).
92
- * @param {{ollamaBase?: string, embedModel?: string}} cfg
109
+ * Configure both legs. `remote*` 可选;不传则远程腿沿用环境变量/默认值。
110
+ * @param {{ollamaBase?: string, embedModel?: string, embedTimeoutMs?: number,
111
+ * remoteBase?: string, remoteModel?: string, remoteKey?: string}} cfg
93
112
  */
94
113
  export function configureSemantic(cfg = {}) {
95
- if (cfg && typeof cfg === "object") {
96
- if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
97
- if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
114
+ if (!cfg || typeof cfg !== "object") return;
115
+ if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
116
+ if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
117
+ if (Number.isFinite(cfg.embedTimeoutMs) && cfg.embedTimeoutMs >= 100) _timeout = cfg.embedTimeoutMs;
118
+ if (cfg.remoteBase || cfg.remoteModel || typeof cfg.remoteKey === "string") {
119
+ configureRemoteEmbed({
120
+ apiBase: cfg.remoteBase,
121
+ model: cfg.remoteModel,
122
+ apiKey: cfg.remoteKey,
123
+ timeoutMs: cfg.embedTimeoutMs,
124
+ });
98
125
  }
99
126
  }
100
127
 
101
- /** One embedding vector for a text, or null on any failure. */
102
- export async function embedText(text, timeoutMs = EMBED_TIMEOUT_MS) {
128
+ /** 缓存的身份键:换了端点或模型就必须整体重建,不能复用旧向量。 */
129
+ export function semanticProvider() {
130
+ return remoteEmbedAvailable()
131
+ ? `remote|${process.env.DSH_EMBED_BASE || "ark"}|${process.env.DSH_EMBED_MODEL || _model}`
132
+ : `ollama|${_base}|${_model}`;
133
+ }
134
+
135
+ /* ------------------------------------------------------------------ */
136
+ /* embedding legs */
137
+ /* ------------------------------------------------------------------ */
138
+
139
+ async function ollamaEmbed(text, timeoutMs) {
103
140
  const s = String(text || "").trim();
104
141
  if (!s) return null;
105
142
  try {
@@ -120,10 +157,42 @@ export async function embedText(text, timeoutMs = EMBED_TIMEOUT_MS) {
120
157
  clearTimeout(timer);
121
158
  }
122
159
  } catch {
123
- return null; // offline / timeout / bad response → lexical leg only
160
+ return null;
124
161
  }
125
162
  }
126
163
 
164
+ /** 单条 embedding:远程优先,失败回落本地 Ollama,最后 null。 */
165
+ export async function embedText(text, timeoutMs = _timeout) {
166
+ const s = String(text || "").trim();
167
+ if (!s) return null;
168
+ if (remoteEmbedAvailable()) {
169
+ const rv = await remoteEmbed(s, timeoutMs);
170
+ if (rv) return rv;
171
+ }
172
+ return ollamaEmbed(s, timeoutMs);
173
+ }
174
+
175
+ /**
176
+ * 批量 embedding:远程可用时走批量(4 条/批 + 退避),否则逐条 embedText。
177
+ * @param {string[]} texts
178
+ * @param {number} [timeoutMs]
179
+ * @param {{batchSize?: number, concurrency?: number, onProgress?: Function}} [opts]
180
+ * @returns {Promise<Array<number[]|null>>}
181
+ */
182
+ export async function embedBatch(texts, timeoutMs = _timeout, opts = {}) {
183
+ const list = Array.isArray(texts) ? texts : [];
184
+ if (!list.length) return [];
185
+ if (remoteEmbedAvailable()) {
186
+ const vecs = await remoteEmbedBatch(list, { timeoutMs, ...opts });
187
+ if (vecs.some(Boolean)) return vecs;
188
+ }
189
+ const out = new Array(list.length).fill(null);
190
+ for (let i = 0; i < list.length; i++) {
191
+ out[i] = await embedText(list[i], timeoutMs);
192
+ }
193
+ return out;
194
+ }
195
+
127
196
  /** Cosine similarity in [0,1] (both vectors plain arrays). */
128
197
  export function cosine(a, b) {
129
198
  if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0;
@@ -139,93 +208,141 @@ export function cosine(a, b) {
139
208
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
140
209
  }
141
210
 
211
+ /* ------------------------------------------------------------------ */
212
+ /* query embedding cache (fold 路径不能等网络) */
213
+ /* ------------------------------------------------------------------ */
214
+
215
+ const queryCache = new Map(); // norm text -> {vec, ts}
216
+
217
+ function normQuery(q) {
218
+ return String(q || "").trim().toLowerCase().replace(/\s+/g, " ").slice(0, 400);
219
+ }
220
+
221
+ /**
222
+ * 同步读取已缓存的查询向量(供 assemble 热路径使用,绝不等网络)。
223
+ * @returns {number[]|null}
224
+ */
225
+ export function getCachedQueryVector(query) {
226
+ const k = normQuery(query);
227
+ if (!k) return null;
228
+ const hit = queryCache.get(k);
229
+ if (!hit) return null;
230
+ if (Date.now() - hit.ts > QUERY_TTL_MS) {
231
+ queryCache.delete(k);
232
+ return null;
233
+ }
234
+ return hit.vec;
235
+ }
236
+
237
+ /** 查询向量(带缓存);失败返回 null。 */
238
+ export async function embedQueryCached(query, timeoutMs = _timeout) {
239
+ const k = normQuery(query);
240
+ if (!k) return null;
241
+ const hit = queryCache.get(k);
242
+ if (hit && Date.now() - hit.ts <= QUERY_TTL_MS) return hit.vec;
243
+ const vec = await embedText(query, timeoutMs);
244
+ if (vec) {
245
+ queryCache.set(k, { vec, ts: Date.now() });
246
+ if (queryCache.size > QUERY_CACHE_MAX) {
247
+ const oldest = [...queryCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0];
248
+ if (oldest) queryCache.delete(oldest[0]);
249
+ }
250
+ }
251
+ return vec;
252
+ }
253
+
142
254
  /* ------------------------------------------------------------------ */
143
255
  /* Semantic index with persistent doc-embedding cache */
144
256
  /* ------------------------------------------------------------------ */
145
257
 
146
258
  /**
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.
259
+ * Build a semantic index over docs with per-doc fingerprint incremental diff.
260
+ * 与旧实现的差别:
261
+ * 1. 走 embedBatch(批量 + 并发),243 个工具从「数百次串行 HTTP」变成 ~60 次批调用
262
+ * 2. **部分成功也落盘**(旧实现要求全部成功才写缓存 → 一次失败永远重建)
263
+ * 3. 缓存身份键带 provider(远程/本地切换不会复用错向量)
264
+ * 4. 覆盖率 ≥80% 即视为可用(cosine 只对已有向量的 doc 生效)
157
265
  *
158
266
  * @param {Array<{id: string, text: string}>} docs
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).
165
- * @returns {Promise<{vectors: Map<string, number[]>, available: boolean}>}
267
+ * @param {number} [timeoutMs]
268
+ * @param {{embed?: Function, cacheFile?: string, onProgress?: Function}} [opts]
269
+ * @returns {Promise<{vectors: Map<string, number[]>, available: boolean, embedded: number, reused: number}>}
166
270
  */
167
- export async function buildSemanticIndex(docs, timeoutMs = EMBED_TIMEOUT_MS, opts = {}) {
168
- const out = { vectors: new Map(), available: false };
271
+ export async function buildSemanticIndex(docs, timeoutMs = _timeout, opts = {}) {
272
+ const out = { vectors: new Map(), available: false, embedded: 0, reused: 0 };
169
273
  const list = Array.isArray(docs) ? docs.filter((d) => d && d.id && d.text) : [];
170
274
  if (list.length === 0) return out;
171
275
 
172
- const embed = typeof opts.embed === "function" ? opts.embed : embedText;
276
+ const injectable = typeof opts.embed === "function" ? opts.embed : null;
173
277
  const cachePath = opts.cacheFile || defaultCachePath();
278
+ const provider = semanticProvider();
174
279
 
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
280
  const fpOf = (d) => sha1(d.id + "\u0000" + d.text);
178
281
 
179
282
  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
283
  const modelMatch =
184
- cache && cache.model &&
185
- cache.model.model === _model && cache.model.base === _base &&
186
- cache.docs && typeof cache.docs === "object";
284
+ cache &&
285
+ cache.model &&
286
+ (cache.model.provider === provider ||
287
+ (!cache.model.provider && cache.model.model === _model && cache.model.base === _base)) &&
288
+ cache.docs &&
289
+ typeof cache.docs === "object";
187
290
  const cachedDocs = modelMatch ? cache.docs : null;
188
291
 
189
- // Embed every doc whose fingerprint misses the cache; reuse cached vectors.
190
- const fresh = {}; // id -> vector, only for newly embedded
191
- const deadline = Date.now() + 60_000;
292
+ // 1) 复用缓存里指纹未变的向量,收集需要重算的 doc
293
+ const need = [];
294
+ const fpById = new Map();
192
295
  for (const d of list) {
193
- if (Date.now() > deadline) break;
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
- }
200
- }
201
- if (!v) {
202
- v = await embed(d.text, timeoutMs);
203
- if (v) fresh[d.id] = v;
296
+ const fp = fpOf(d);
297
+ fpById.set(d.id, fp);
298
+ const hit = cachedDocs && cachedDocs[fp];
299
+ if (hit && Array.isArray(hit.vec) && hit.vec.length > 0 && hit.id === d.id) {
300
+ out.vectors.set(d.id, hit.vec);
301
+ out.reused += 1;
302
+ } else {
303
+ need.push(d);
204
304
  }
205
- if (v) out.vectors.set(d.id, v);
206
305
  }
207
306
 
208
- if (out.vectors.size === list.length) {
209
- out.available = true;
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] };
307
+ // 2) 批量补算缺失向量
308
+ if (need.length) {
309
+ let fresh = new Array(need.length).fill(null);
310
+ if (injectable) {
311
+ for (let i = 0; i < need.length; i++) {
312
+ fresh[i] = await injectable(need[i].text, timeoutMs);
220
313
  }
314
+ } else {
315
+ const texts = need.map((d) => d.text);
316
+ fresh = await embedBatch(texts, timeoutMs, {
317
+ batchSize: 4,
318
+ concurrency: 2,
319
+ onProgress: opts.onProgress,
320
+ });
221
321
  }
222
- writeCacheFileAt(cachePath, { model: { base: _base, model: _model }, docs: newDocs });
322
+ for (let i = 0; i < need.length; i++) {
323
+ if (fresh[i]) {
324
+ out.vectors.set(need[i].id, fresh[i]);
325
+ out.embedded += 1;
326
+ }
327
+ }
328
+ }
329
+
330
+ // 3) 落盘(部分成功也写,避免「一次失败永远重建」)
331
+ const newDocs = {};
332
+ for (const d of list) {
333
+ const v = out.vectors.get(d.id);
334
+ if (v) newDocs[fpById.get(d.id)] = { id: d.id, vec: v };
223
335
  }
336
+ if (Object.keys(newDocs).length) {
337
+ writeCacheFileAt(cachePath, { model: { provider, base: _base, model: _model }, docs: newDocs });
338
+ }
339
+
340
+ out.available = out.vectors.size > 0 && out.vectors.size >= Math.ceil(list.length * 0.8);
224
341
  return out;
225
342
  }
226
343
 
227
344
  /**
228
- * Rank docs by semantic similarity to a query.
345
+ * Rank docs by semantic similarity to a query (查询向量走缓存).
229
346
  * @param {Map<string, number[]>} vectors id → vector
230
347
  * @param {Array<{id: string, text: string}>} docs
231
348
  * @param {string} query
@@ -234,8 +351,20 @@ export async function buildSemanticIndex(docs, timeoutMs = EMBED_TIMEOUT_MS, opt
234
351
  export async function searchSemantic(vectors, docs, query) {
235
352
  const q = String(query || "").trim();
236
353
  if (!q || !vectors || vectors.size === 0) return [];
237
- const qv = await embedText(q);
354
+ const qv = await embedQueryCached(q);
355
+ if (!qv) return [];
356
+ return rankByVector(qv, vectors, docs);
357
+ }
358
+
359
+ /** 同步版:只用已缓存的查询向量(fold 热路径用)。 */
360
+ export function searchSemanticCached(vectors, docs, query) {
361
+ if (!vectors || vectors.size === 0) return [];
362
+ const qv = getCachedQueryVector(query);
238
363
  if (!qv) return [];
364
+ return rankByVector(qv, vectors, docs);
365
+ }
366
+
367
+ function rankByVector(qv, vectors, docs) {
239
368
  const scored = [];
240
369
  for (const d of docs) {
241
370
  const v = vectors.get(d.id);
@@ -248,35 +377,79 @@ export async function searchSemantic(vectors, docs, query) {
248
377
  }
249
378
 
250
379
  /* ------------------------------------------------------------------ */
251
- /* RRF hybrid: merge a BM25 ranking and a semantic ranking */
380
+ /* Weighted RRF: merge BM25 ranking and semantic ranking */
252
381
  /* ------------------------------------------------------------------ */
253
382
 
254
383
  const RRF_K = 60;
384
+ const DEFAULT_SEM_WEIGHT = 3;
385
+ const DEFAULT_BM_WEIGHT = 0.25;
386
+
387
+ /**
388
+ * 自适应 BM25 权重(v0.3.0)。
389
+ *
390
+ * 实测依据(43 个真实工具 / 18 条真实中文意图):
391
+ * BM25 单腿 Hit@1 6/18 · 语义单腿 11/18 · 1:1 融合 8/18
392
+ * 语义 3 : BM25 0.25 → 11/18(与语义单腿同水平,且保留 BM25 的精确词兜底)
393
+ *
394
+ * 判据:查询里的 ASCII 词(>=3 字符)是否出现在 BM25 首条的 名称/描述 中。
395
+ * - 有 → 这是真词匹配(工具名、server 名、英文关键词),BM25 可信,权重 1
396
+ * - 没有(纯中文,BM25 只靠 CJK bigram 命中英文描述)→ 降权到 0.25,
397
+ * 否则噪声项会和语义腿"双命中",被 RRF 加成顶到正确项前面
398
+ */
399
+ export function adaptiveBmWeight(query, topHitName, topHitText) {
400
+ const ascii = String(query || "").toLowerCase().match(/[a-z0-9][a-z0-9_+-]{2,}/g) || [];
401
+ if (!ascii.length) return DEFAULT_BM_WEIGHT;
402
+ const hay = `${topHitName || ""} ${topHitText || ""}`.toLowerCase();
403
+ return ascii.some((t) => hay.includes(t)) ? 1 : DEFAULT_BM_WEIGHT;
404
+ }
255
405
 
256
406
  /**
257
407
  * Reciprocal-rank fusion of two ranked id lists.
258
- * @param {Array<{id: string}>} bm25Ranked BM25 hits in rank order
259
- * @param {Array<{id: string}>} semRanked semantic hits in rank order
408
+ *
409
+ * v0.3.0:语义腿不再是 1:1 等权。技能侧重测(131 技能 / 12 意图)显示
410
+ * 语义明显更准(Hit@1 83% vs 58%),工具侧同样存在「中文意图 / 英文工具」
411
+ * 的跨语言鸿沟,所以给语义腿更高权重;BM25 退化为「精确词与工具名匹配」。
412
+ *
413
+ * 但**单纯调高语义权重不够**(2026-10-17 实测):纯中文查询下 BM25 只靠 CJK
414
+ * bigram 命中一堆无关工具,这些噪声项同时出现在两条腿里,RRF 的"双命中"加成
415
+ * 会把它们顶到正确项前面(Hit@1 从 11/18 掉到 0/18)。所以 BM25 腿也要可降权:
416
+ * 调用方按「BM25 的命中是否含真实 ASCII 词匹配」决定 bmWeight(1 或 0.25)。
417
+ *
418
+ * @param {Array<{id: string}>} bm25Ranked
419
+ * @param {Array<{id: string}>} semRanked
260
420
  * @param {number} topK
261
- * @returns {Array<{id: string, rrf: number, from: string}>} fused, sorted desc
421
+ * @param {{semWeight?: number, bmWeight?: number}} [opts]
262
422
  */
263
- export function rrfFuse(bm25Ranked, semRanked, topK) {
423
+ export function rrfFuse(bm25Ranked, semRanked, topK, opts = {}) {
424
+ const w = Number(opts.semWeight);
425
+ const semWeight = Number.isFinite(w) && w >= 0 ? w : DEFAULT_SEM_WEIGHT;
426
+ const bw = Number(opts.bmWeight);
427
+ const bmWeight = Number.isFinite(bw) && bw >= 0 ? bw : DEFAULT_BM_WEIGHT;
264
428
  const scores = new Map();
265
429
  const from = new Map();
266
- bm25Ranked.forEach((h, i) => {
267
- const key = h.id;
268
- scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
269
- from.set(key, (from.get(key) || "") + "bm25 ");
430
+ const seen = new Set();
431
+ // v0.3.0 修复:同名 id 只取首次出现。语料里若出现重复项(实测工具语料里
432
+ // list_sessions / get_config 各两份),RRF 会把同一 id 的分数累加两次,
433
+ // 直接把无关工具顶到第一——纯语义单腿 11/18 被拉到 2/18 的就是这个。
434
+ (Array.isArray(bm25Ranked) ? bm25Ranked : []).forEach((h, i) => {
435
+ if (!h || !h.id || seen.has(h.id)) return;
436
+ seen.add(h.id);
437
+ scores.set(h.id, bmWeight / (RRF_K + i + 1));
438
+ from.set(h.id, `${from.get(h.id) || ""}bm25 `);
270
439
  });
271
- semRanked.forEach((h, i) => {
272
- const key = h.id;
273
- scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
274
- from.set(key, (from.get(key) || "") + "sem ");
440
+ (Array.isArray(semRanked) ? semRanked : []).forEach((h, i) => {
441
+ if (!h || !h.id) return;
442
+ // 语义腿的名次必须按**去重后的位置**计,否则重复项会挤占后面的名次
443
+ if (scores.has(h.id) && from.get(h.id)?.includes("sem")) return;
444
+ scores.set(h.id, (scores.get(h.id) || 0) + semWeight / (RRF_K + i + 1));
445
+ from.set(h.id, `${from.get(h.id) || ""}sem `);
275
446
  });
276
447
  const k = Number(topK);
277
448
  const n = Number.isFinite(k) && k > 0 ? Math.floor(k) : 5;
278
449
  return [...scores.entries()]
279
450
  .sort((a, b) => b[1] - a[1])
280
451
  .slice(0, n)
281
- .map(([id, r]) => ({ id, rrf: r, from: from.get(id)?.trim() || "" }));
452
+ .map(([id, r]) => ({ id, rrf: r, from: (from.get(id) || "").trim() }));
282
453
  }
454
+
455
+ export { DEFAULT_SEM_WEIGHT, DEFAULT_BM_WEIGHT };
package/lib/ux.js CHANGED
@@ -87,8 +87,14 @@ export function expandQuery(q) {
87
87
  /* ------------------------- ② 零匹配回退轻目录 ------------------------- */
88
88
 
89
89
  /**
90
- * 轻目录:按名字典序排序取前 limit 个(name + 描述截 80 + server)。
90
+ * 轻目录:跨 server 轮转配额(name + 描述截 80 + server)。
91
91
  * limit<=0 → [](关闭回退,行为与旧版一致)。
92
+ *
93
+ * 2026-09-02(审计修复):纯字典序 slice(0, limit) 会让名字靠后的 server
94
+ * (mcp__windows 等)在截断后全部不可见 → 模型零匹配回退时误判"没有该能力"。
95
+ * 改为:按 server 分组(组内字典序)→ round-robin 出队——小 server 必有代表、
96
+ * 大 server 拿多数、同输入同输出(确定性)。server 函数为每工具独立名时行为
97
+ * 与旧字典序一致(每组 1 个,轮转 = 字典序)。
92
98
  * @param {Array<{name:string,description?:string}>} allTools
93
99
  * @param {number} limit
94
100
  * @param {(name:string)=>string} [serverOf]
@@ -98,14 +104,28 @@ export function fallbackCatalog(allTools, limit, serverOf) {
98
104
  const lim = Math.max(0, Number(limit) || 0);
99
105
  if (!lim || !Array.isArray(allTools) || allTools.length === 0) return [];
100
106
  const svc = typeof serverOf === "function" ? serverOf : () => "";
101
- return [...allTools]
102
- .sort((a, b) => String(a.name).localeCompare(String(b.name)))
103
- .slice(0, lim)
104
- .map((t) => ({
105
- name: String(t.name || ""),
106
- description: String(t.description || "").slice(0, 80),
107
- server: svc(t.name),
108
- }));
107
+ const byServer = new Map();
108
+ for (const t of allTools) {
109
+ const s = svc(t.name) || "(none)";
110
+ if (!byServer.has(s)) byServer.set(s, []);
111
+ byServer.get(s).push(t);
112
+ }
113
+ // Deterministic: server order by name, tools inside each server sorted by name.
114
+ const queues = [...byServer.entries()]
115
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0])))
116
+ .map(([, ts]) => ts.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))));
117
+ const out = [];
118
+ while (out.length < lim && queues.some((q) => q.length > 0)) {
119
+ for (const q of queues) {
120
+ if (out.length >= lim) break;
121
+ if (q.length > 0) out.push(q.shift());
122
+ }
123
+ }
124
+ return out.map((t) => ({
125
+ name: String(t.name || ""),
126
+ description: String(t.description || "").slice(0, 80),
127
+ server: svc(t.name),
128
+ }));
109
129
  }
110
130
 
111
131
  /* ------------------------- ③ 检索结果缓存 ------------------------- */
package/package.json CHANGED
@@ -1,48 +1,56 @@
1
- {
2
- "name": "dsh-tool-folder",
3
- "version": "0.2.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
- "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
- }
1
+ {
2
+ "name": "dsh-tool-folder",
3
+ "version": "0.3.0",
4
+ "description": "Fold the DSH tool surface per request + ChainGuard firewall. v0.3.0: remote-first semantic retrieval (Ark doubao-embedding / Ollama fallback), weighted RRF with adaptive BM25 downweight, batched incremental index (partial results cached), body-level rerank (opt-in), duplicate-id fusion fix.",
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
+ },
48
+ "peerDependenciesMeta": {
49
+ "@deepseek-ai/cordis": {
50
+ "optional": true
51
+ },
52
+ "@deepseek-ai/dsh-tools": {
53
+ "optional": true
54
+ }
55
+ }
56
+ }