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/LICENSE +21 -0
- package/README.md +62 -0
- package/lib/bm25.js +127 -112
- package/lib/chainguard.js +40 -0
- package/lib/embed-remote.js +193 -0
- package/lib/index.js +225 -54
- package/lib/obfuscation.js +11 -2
- package/lib/rerank.js +115 -0
- package/lib/semantic.js +274 -101
- package/lib/ux.js +29 -9
- package/package.json +56 -48
package/lib/semantic.js
CHANGED
|
@@ -1,31 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* semantic.js —
|
|
2
|
+
* semantic.js — 语义检索腿(远程优先 → 本地 Ollama → 纯 BM25 降级)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
* Ollama
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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);
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
92
|
-
* @param {{ollamaBase?: string, embedModel?: string
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
/**
|
|
102
|
-
export
|
|
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;
|
|
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
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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]
|
|
160
|
-
* @param {{embed?: Function, cacheFile?: string}} [opts]
|
|
161
|
-
*
|
|
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 =
|
|
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
|
|
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 &&
|
|
185
|
-
cache.model
|
|
186
|
-
cache.
|
|
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
|
-
//
|
|
190
|
-
const
|
|
191
|
-
const
|
|
292
|
+
// 1) 复用缓存里指纹未变的向量,收集需要重算的 doc
|
|
293
|
+
const need = [];
|
|
294
|
+
const fpById = new Map();
|
|
192
295
|
for (const d of list) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
259
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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)
|
|
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
|
-
*
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
.
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|