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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hfhh32
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -149,6 +149,68 @@ were never unregistered, so nothing else changes.
|
|
|
149
149
|
|
|
150
150
|
## Changelog
|
|
151
151
|
|
|
152
|
+
### v0.3.0 — 语义腿真正可用 + 融合修正(2026-10-17)
|
|
153
|
+
|
|
154
|
+
**动机(实测发现的真问题,不是推测)**:本机没装 Ollama(`127.0.0.1:11434` 返回
|
|
155
|
+
HTTP 000),`embedText` 恒为 null → `tools_search` 实际一直是**纯 BM25**。在 43 个
|
|
156
|
+
真实工具(`tools-real.json`)、18 条真实中文意图上测:**Hit@1 只有 6/18(33%)**。
|
|
157
|
+
|
|
158
|
+
1. **远程 embedding 腿(`lib/embed-remote.js` 新增)**:OpenAI 兼容端点,默认火山
|
|
159
|
+
`doubao-embedding-vision`(2048 维),key 读 `DSH_EMBED_KEY` / `OPENVIKING_ARK_KEY`。
|
|
160
|
+
带熔断(连续 3 次失败 → 5 分钟冷却)+ 批量(4 条/批、2 并发、429 退避 0.8/2/5s)。
|
|
161
|
+
纯语义 Hit@1 **11/18(61%)**。
|
|
162
|
+
2. **加权 RRF + 自适应 BM25 权重**:语义腿默认权重 3,BM25 默认 0.25;
|
|
163
|
+
若查询里的 ASCII 词命中 BM25 首条的名称/描述(真实词匹配、工具名、server 名),
|
|
164
|
+
BM25 权重回升到 1。实测 1:1 融合只有 8/18,加权后 **11/18**。
|
|
165
|
+
3. **重复 id 修复(关键)**:语料里同名工具出现两次时,旧 RRF 会把同一 id 的分累加
|
|
166
|
+
两次——`list_sessions` / `get_config` 各两份,直接把正确项从第一顶到第三以外
|
|
167
|
+
(纯语义 11/18 被拉到 2/18)。现在 bm25/语义两腿都按 id 去重,组装侧也先按名去重。
|
|
168
|
+
4. **索引可用性修复**:旧实现要求**全部** doc 成功才落缓存(一次失败就永远重建),
|
|
169
|
+
且 60s 硬截止串行调用;改为批量并发 + **部分成功也落盘** + 覆盖率 ≥80% 即可用。
|
|
170
|
+
5. **缓存路径修正**:`~/.dsh/state/` → 优先 `$DSH_HOME/state/`(DSH_HOME 指向
|
|
171
|
+
`.dsh-community` 时旧实现会把缓存写到另一个目录)。缓存身份键带 provider,
|
|
172
|
+
远程/本地切换不会复用错向量。
|
|
173
|
+
6. **索引不再阻塞**:在 assemble 后台构建,`tools_search` 绝不等待冷启动(旧实现
|
|
174
|
+
首次搜索可能卡 30s+);查询向量有内存缓存 + 15s 节流后台预热。
|
|
175
|
+
7. **正文精排(`lib/rerank.js` 新增,默认关)**:借鉴 SkillRouter 的两阶段检索,
|
|
176
|
+
在候选集内用「完整描述 + 参数名 + 参数描述 + 枚举」重排。**实测在本语料上
|
|
177
|
+
Hit@1 持平、Hit@3 反而 -1**(15→14),所以默认关闭,`bodyRerank: true` 可开。
|
|
178
|
+
8. **语义腿参与自动注入**(`semanticDynamic`,默认开):只用已缓存的查询向量,
|
|
179
|
+
零额外延迟;命中就把结果并进动态段。
|
|
180
|
+
9. `debugDump`:把当前工具语料 dump 成 JSON,供离线评测复现。
|
|
181
|
+
10. 测试 114 → **123 条**,全绿;新增 `scripts/eval-tools.mjs` 离线评测脚本。
|
|
182
|
+
|
|
183
|
+
**实测汇总(43 工具 / 18 中文意图)**
|
|
184
|
+
|
|
185
|
+
| 方案 | Hit@1 | Hit@3 |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| BM25(v0.2.5 实际状态) | 6/18 | 8/18 |
|
|
188
|
+
| RRF 1:1 | 8/18 | 12/18 |
|
|
189
|
+
| 语义单腿(参考上限) | 11/18 | 15/18 |
|
|
190
|
+
| **v0.3.0 默认(语义3 : BM25 0.25 自适应)** | **11/18** | **15/18** |
|
|
191
|
+
| 再加正文精排 0.35 | 11/18 | 14/18 |
|
|
192
|
+
|
|
193
|
+
### v0.2.5 — red-team firewall fixes + retrieval hardening (2026-09-02)
|
|
194
|
+
1. **Exec-tool recognition (red-team F1)** — bare-name matching let MCP-prefixed
|
|
195
|
+
exec tools (`mcp__windows__Cmd`) bypass the high-risk gate; `isExecTool` now
|
|
196
|
+
tokenizes the full name and matches every segment (all segments participate —
|
|
197
|
+
conservative by design, over-recognition is ~free on the gate).
|
|
198
|
+
2. **False-positive roots A/B** — Windows paths are base64-shaped (57-char fake
|
|
199
|
+
runs) and the bare `&` of `2>&1` looked like an exec verb; a legit gitleaks
|
|
200
|
+
staged scan was blocked. Path-fingerprint exclusion + `EXEC_VERB` tightening.
|
|
201
|
+
2/5 measured FPs → 0/5; regression suite `chainguard-fp.test.js` (114 total green).
|
|
202
|
+
3. **Command-substitution download-exec legs** — `$(curl …)` / backtick /
|
|
203
|
+
`iwr` shapes (no pipe, invisible to the download-pipe pattern).
|
|
204
|
+
4. **BM25 sub-word split** — hyphen/underscore tool names were single index
|
|
205
|
+
terms (`mcp__codegraph__codegraph_explore`); measured recall 0/10 → 10/10.
|
|
206
|
+
5. **fallbackCatalog round-robin** — pure lexicographic slice hid whole servers
|
|
207
|
+
(`mcp__*`) from the fallback catalog; now round-robins across servers.
|
|
208
|
+
6. **serverOf unified** — apply-local `split("__")` copy deleted; module-level
|
|
209
|
+
grouping (native/openhands families) is the single source for search results
|
|
210
|
+
and sibling closure. `embedTimeoutMs` configurable (default 8000).
|
|
211
|
+
7. Two-axis audit (2026-09-02): comments/tests now match the real conservative
|
|
212
|
+
design; one-shot repro script removed.
|
|
213
|
+
|
|
152
214
|
### v0.2.4 — P1: batch tools_schema + sibling closure (2026-08-31)
|
|
153
215
|
1. **Batch expand** — `tools_schema` accepts `names: [...]` to expand several
|
|
154
216
|
tools in one call (`{ found, results: [...] }`); a single `name` keeps the
|
package/lib/bm25.js
CHANGED
|
@@ -1,112 +1,127 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* bm25.js — zero-dependency BM25 lexical search for tool retrieval.
|
|
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
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
for
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
1
|
+
/**
|
|
2
|
+
* bm25.js — zero-dependency BM25 lexical search for tool retrieval.
|
|
3
|
+
*
|
|
4
|
+
* VENDOR SOURCE (2026-09-02): dsh-skill-folder/lib/bm25.js vendors this file
|
|
5
|
+
* verbatim (comments may diverge per repo). Modify here, then mirror the code
|
|
6
|
+
* change into the skill repo. Both copies are kept in sync by the maintainer.
|
|
7
|
+
* semantic.js is intentionally DIVERGED (tool has incremental cache/sha1) and
|
|
8
|
+
* is NOT covered by the gate.
|
|
9
|
+
*
|
|
10
|
+
* Design (ported from the xiaowan agent's ToolBM25Index idea; the algorithm is
|
|
11
|
+
* the reference, this code is a clean-room JS implementation):
|
|
12
|
+
* - BM25 with standard k1=1.2, b=0.75 (RAG-MCP / BoR validated the
|
|
13
|
+
* retrieval-first approach; BM25 is the cheap lexical leg).
|
|
14
|
+
* - Tokenizer handles mixed CN/EN: ASCII words + CJK bigrams. No jieba, no
|
|
15
|
+
* network, no dependencies — must run inside the DSH plugin runtime.
|
|
16
|
+
* - Synchronous and fast: indexing ~50 tools is <1ms, a query <1ms.
|
|
17
|
+
*
|
|
18
|
+
* Cordis sandbox note: this module only does string/number math. It never
|
|
19
|
+
* touches fs/net/process, so it is safe in any host or sandbox context.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const K1 = 1.2;
|
|
23
|
+
const B = 0.75;
|
|
24
|
+
|
|
25
|
+
const EN_STOP = new Set([
|
|
26
|
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
|
|
27
|
+
"at", "by", "from", "as", "is", "are", "was", "were", "be", "been",
|
|
28
|
+
"it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
|
|
29
|
+
"they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
|
|
30
|
+
"will", "would", "can", "could", "should", "may", "might", "must",
|
|
31
|
+
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Tokenize mixed CN/EN text into lowercase tokens. */
|
|
35
|
+
function tokenize(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const s = String(text || "").toLowerCase();
|
|
38
|
+
for (const m of s.matchAll(/[a-z0-9][a-z0-9_+-]{1,}/g)) {
|
|
39
|
+
const t = m[0];
|
|
40
|
+
if (!EN_STOP.has(t)) out.push(t);
|
|
41
|
+
// Sub-words of hyphen/underscore compounds. Without this, a tool name like
|
|
42
|
+
// "mcp__open-design__start_run" is ONE term, so querying "design" scores
|
|
43
|
+
// zero. The whole token is kept too, so exact-name matches and existing
|
|
44
|
+
// IDF are unchanged; sub-words only ADD recall.
|
|
45
|
+
if (t.length > 2 && (t.includes("-") || t.includes("_"))) {
|
|
46
|
+
for (const part of t.split(/[-_]+/)) {
|
|
47
|
+
if (part.length > 1 && part !== t && !EN_STOP.has(part)) out.push(part);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// CJK bigrams: "开源框架" -> "开源","源框","框架". Bigrams beat single
|
|
52
|
+
// chars for short tool descriptions and are dependency-free.
|
|
53
|
+
const cjk = s.replace(/[^\u4e00-\u9fff]/g, "");
|
|
54
|
+
if (cjk.length >= 2) {
|
|
55
|
+
for (let i = 0; i + 2 <= cjk.length; i++) out.push(cjk.slice(i, i + 2));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build a BM25 index over an array of docs. Each doc: {id, text, ...}. */
|
|
61
|
+
function buildIndex(docs) {
|
|
62
|
+
const n = docs.length;
|
|
63
|
+
const docTokens = [];
|
|
64
|
+
const df = new Map(); // term -> #docs containing it
|
|
65
|
+
let totalLen = 0;
|
|
66
|
+
|
|
67
|
+
for (const doc of docs) {
|
|
68
|
+
const tokens = tokenize(doc.text);
|
|
69
|
+
docTokens.push(tokens);
|
|
70
|
+
totalLen += tokens.length;
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const t of tokens) {
|
|
73
|
+
if (!seen.has(t)) {
|
|
74
|
+
seen.add(t);
|
|
75
|
+
df.set(t, (df.get(t) || 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const avgdl = n > 0 ? totalLen / n : 1;
|
|
80
|
+
|
|
81
|
+
return { n, docTokens, df, avgdl };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Score one doc's token list against the query's IDF map. */
|
|
85
|
+
function score(tokens, queryIdf, n, avgdl) {
|
|
86
|
+
const tf = new Map();
|
|
87
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
88
|
+
let acc = 0;
|
|
89
|
+
const dl = tokens.length;
|
|
90
|
+
for (const [term, idf] of queryIdf) {
|
|
91
|
+
const f = tf.get(term);
|
|
92
|
+
if (!f) continue;
|
|
93
|
+
acc += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * dl) / avgdl)));
|
|
94
|
+
}
|
|
95
|
+
return acc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Search an index. Returns up to topK doc ids sorted by BM25 score desc.
|
|
100
|
+
* @param index result of buildIndex
|
|
101
|
+
* @param query raw query string (mixed CN/EN ok)
|
|
102
|
+
* @param topK max results (default 10)
|
|
103
|
+
* @param opts {filter: (docId) => boolean}
|
|
104
|
+
*/
|
|
105
|
+
function search(index, query, topK = 10, opts = {}) {
|
|
106
|
+
const { n, docTokens, df, avgdl } = index;
|
|
107
|
+
if (!n || !query) return [];
|
|
108
|
+
const qTerms = tokenize(query);
|
|
109
|
+
if (qTerms.length === 0) return [];
|
|
110
|
+
|
|
111
|
+
const queryIdf = new Map();
|
|
112
|
+
for (const t of qTerms) {
|
|
113
|
+
const d = df.get(t) || 0;
|
|
114
|
+
// BM25+ style: add 1 inside the log to keep IDF positive for unseen terms.
|
|
115
|
+
queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const scored = [];
|
|
119
|
+
for (let i = 0; i < n; i++) {
|
|
120
|
+
const s = score(docTokens[i], queryIdf, n, avgdl);
|
|
121
|
+
if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
|
|
122
|
+
}
|
|
123
|
+
scored.sort((a, b) => b[1] - a[1]);
|
|
124
|
+
return scored.slice(0, topK).map(([i]) => i);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { tokenize, buildIndex, search, score };
|
package/lib/chainguard.js
CHANGED
|
@@ -24,6 +24,9 @@ const HIGH_RISK_PATTERNS = [
|
|
|
24
24
|
// download → execute chain
|
|
25
25
|
[/(wget|curl|powershell|pwsh).{0,80}(\||;|&&).{0,30}(sh|bash|powershell|pwsh|cmd|start)\b/i, "download-pipe-execute"],
|
|
26
26
|
[/invoke-expression|iex\s*\(|iwr\s+.*\|.*iex/i, "powershell download-exec"],
|
|
27
|
+
// command substitution download → execute (bash $() and backticks; no pipe,
|
|
28
|
+
// so the download-pipe pattern above cannot see it). Added 2026-09-02.
|
|
29
|
+
[/\$\s*\(\s*(?:curl|wget|iwr)[^)]{0,120}\)|\`(?:curl|wget|iwr)[^\`]{0,120}\`/i, "download-exec via command substitution"],
|
|
27
30
|
// credential / exfil
|
|
28
31
|
// NOTE: `\\+` (one-or-more literal backslashes) instead of `\\` — argv text
|
|
29
32
|
// reaches verdict() both as a raw string (single backslash) and as
|
|
@@ -58,6 +61,43 @@ function matchHighRisk(text) {
|
|
|
58
61
|
return null;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 是否「执行类工具」——参数是真实要执行的命令/脚本,需要高危硬拦。
|
|
66
|
+
* 内容类工具(write/edit/read/apply_patch 等)的参数是文件内容/文本,
|
|
67
|
+
* 本身不执行,含危险命令字面量(讨论/测试/文档)不应触发硬拦。
|
|
68
|
+
*
|
|
69
|
+
* 2026-08-30: 修复 ChainGuard 对所有工具参数跑高危正则导致的误拦。
|
|
70
|
+
* 2026-09-02 (red-team F1): bare-name matching let MCP-prefixed exec tools
|
|
71
|
+
* (`mcp__windows__Cmd`) bypass the gate — tokenize the full name and match
|
|
72
|
+
* every segment against the verb list. Over-recognition is ~free here: the
|
|
73
|
+
* gate only decides "run this argv past the firewall", and a non-command
|
|
74
|
+
* argv has no high-risk literals to trip; missing one tool is a bypass.
|
|
75
|
+
* @param {string} name - full tool name (mcp__<server>__<tool> or bare).
|
|
76
|
+
*/
|
|
77
|
+
export function isExecTool(name) {
|
|
78
|
+
if (!name) return false;
|
|
79
|
+
const n = String(name).toLowerCase();
|
|
80
|
+
const EXEC_TOKENS = new Set([
|
|
81
|
+
"cmd", "command", "console", "terminal", "shell", "bash", "pwsh", "powershell",
|
|
82
|
+
"exec", "execute", "executor", "run", "runner", "sh", "zsh", "script", "ps1",
|
|
83
|
+
]);
|
|
84
|
+
// Legacy fast paths kept for bare names and native prefixes.
|
|
85
|
+
const EXEC_NAMES = ["pwsh", "shell", "bash", "cmd", "exec", "terminal", "run", "console"];
|
|
86
|
+
if (EXEC_NAMES.includes(n)) return true;
|
|
87
|
+
if (n.endsWith("__app")) return true; // mcp__windows__App:launch_executable 模式可启动任意可执行文件
|
|
88
|
+
if (n.startsWith("run_") || n.startsWith("exec_") || n.startsWith("execute_")) return true;
|
|
89
|
+
if (n.startsWith("ssh_") || n.startsWith("wsl")) return true;
|
|
90
|
+
// Segment match: tokenize on mcp__ separator and _ - . (runbook-style names).
|
|
91
|
+
// All segments except the `mcp` marker participate — a server name carrying
|
|
92
|
+
// an exec word (e.g. `pwsh` in mcp__pwsh__list_tools) is itself an exec
|
|
93
|
+
// signal, while `mcp__github__get_file_contents` has none. Over-recognition
|
|
94
|
+
// is ~free (gate only); under-recognition is a bypass.
|
|
95
|
+
const tokens = n.split(/__|_|-|\./).filter((t) => t.length > 0);
|
|
96
|
+
const meaningful = n.startsWith("mcp") && tokens[0] === "mcp" ? tokens.slice(1) : tokens;
|
|
97
|
+
if (meaningful.length === 0) return false;
|
|
98
|
+
return meaningful.some((t) => EXEC_TOKENS.has(t));
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
export function verdict(argvText) {
|
|
62
102
|
if (!argvText) return null;
|
|
63
103
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embed-remote.js — 远程 OpenAI 兼容 embedding(默认火山方舟 doubao-embedding-vision)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(2026-10-17 实测):v0.2.x 的语义腿只走本地 Ollama bge-m3,
|
|
5
|
+
* 而本机 `127.0.0.1:11434` 返回 HTTP 000(Ollama 未安装)→ `embedText` 恒为 null
|
|
6
|
+
* → `tools_search` 实际退化为纯 BM25。中文意图打英文工具描述时经常零命中。
|
|
7
|
+
*
|
|
8
|
+
* 设计要点(与 dsh-skill-folder v0.4.0 的同名模块同源):
|
|
9
|
+
* - 批量:OpenAI 兼容端点支持 `input: string[]`,一次 4 条(火山限流下的稳定值)
|
|
10
|
+
* - 并发:2 路并发,批间 250ms 节流;429/5xx 退避重试(0.8s / 2s / 5s)
|
|
11
|
+
* - 熔断:连续 3 次失败 → 冷却 5 分钟,期间 `remoteEmbedAvailable()` 为 false,
|
|
12
|
+
* 调用方自动回落到 Ollama / 纯 BM25
|
|
13
|
+
* - 任何失败都返回 null/数组中的 null,绝不抛
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_BASE = "https://ark.cn-beijing.volces.com/api/plan/v3";
|
|
17
|
+
const DEFAULT_MODEL = "doubao-embedding-vision";
|
|
18
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
19
|
+
const FAIL_THRESHOLD = 3;
|
|
20
|
+
const COOLDOWN_MS = 5 * 60 * 1000;
|
|
21
|
+
const DEFAULT_BATCH = 4;
|
|
22
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
23
|
+
const BATCH_GAP_MS = 250;
|
|
24
|
+
const BACKOFF_MS = [800, 2000, 5000];
|
|
25
|
+
|
|
26
|
+
let _base = process.env.DSH_EMBED_BASE || DEFAULT_BASE;
|
|
27
|
+
let _model = process.env.DSH_EMBED_MODEL || DEFAULT_MODEL;
|
|
28
|
+
let _key = process.env.DSH_EMBED_KEY || process.env.OPENVIKING_ARK_KEY || "";
|
|
29
|
+
let _timeout = DEFAULT_TIMEOUT;
|
|
30
|
+
let _fails = 0;
|
|
31
|
+
let _disabledUntil = 0;
|
|
32
|
+
|
|
33
|
+
export function configureRemoteEmbed(cfg = {}) {
|
|
34
|
+
if (!cfg || typeof cfg !== "object") return;
|
|
35
|
+
if (typeof cfg.apiBase === "string" && cfg.apiBase) _base = cfg.apiBase;
|
|
36
|
+
if (typeof cfg.model === "string" && cfg.model) _model = cfg.model;
|
|
37
|
+
if (typeof cfg.apiKey === "string") _key = cfg.apiKey; // 允许显式空串禁用(测试用)
|
|
38
|
+
if (Number.isFinite(Number(cfg.timeoutMs)) && Number(cfg.timeoutMs) >= 100) _timeout = Number(cfg.timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function remoteEmbedAvailable() {
|
|
42
|
+
return Boolean(_key) && Date.now() >= _disabledUntil;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function remoteEmbedState() {
|
|
46
|
+
return {
|
|
47
|
+
available: remoteEmbedAvailable(),
|
|
48
|
+
base: _base,
|
|
49
|
+
model: _model,
|
|
50
|
+
fails: _fails,
|
|
51
|
+
disabledUntil: _disabledUntil,
|
|
52
|
+
hasKey: Boolean(_key),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function trip(reason) {
|
|
57
|
+
_fails += 1;
|
|
58
|
+
if (_fails >= FAIL_THRESHOLD && Date.now() >= _disabledUntil) {
|
|
59
|
+
_disabledUntil = Date.now() + COOLDOWN_MS;
|
|
60
|
+
try {
|
|
61
|
+
console.log(`[tool-folder] remote embedding 熔断(${reason}),${COOLDOWN_MS / 1000}s 内只用本地/词法腿`);
|
|
62
|
+
} catch {
|
|
63
|
+
/* 日志失败不影响功能 */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function reset() {
|
|
69
|
+
_fails = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
73
|
+
|
|
74
|
+
/** 一次 HTTP 调用,input 可以是单条字符串或字符串数组。 */
|
|
75
|
+
async function callOnce(input, timeoutMs) {
|
|
76
|
+
const ctrl = new AbortController();
|
|
77
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`${_base.replace(/\/+$/, "")}/embeddings`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${_key}` },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model: _model,
|
|
84
|
+
input,
|
|
85
|
+
encoding_format: "float",
|
|
86
|
+
}),
|
|
87
|
+
signal: ctrl.signal,
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const body = await res.text().catch(() => "");
|
|
91
|
+
const err = new Error(`HTTP ${res.status} ${body.slice(0, 120)}`);
|
|
92
|
+
err.retryable = res.status === 429 || res.status >= 500;
|
|
93
|
+
err.status = res.status;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
const j = await res.json();
|
|
97
|
+
const rows = Array.isArray(j?.data) ? j.data : [];
|
|
98
|
+
if (!rows.length) throw new Error("empty embedding response");
|
|
99
|
+
// 按 index 对齐(端点不保证顺序)
|
|
100
|
+
const out = rows
|
|
101
|
+
.slice()
|
|
102
|
+
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
103
|
+
.map((r) => (Array.isArray(r?.embedding) && r.embedding.length ? r.embedding.map(Number) : null));
|
|
104
|
+
if (!out.some(Boolean)) throw new Error("no usable embedding");
|
|
105
|
+
return out;
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 单条文本 → 向量;失败返回 null(绝不抛)。 */
|
|
112
|
+
export async function remoteEmbed(text, timeoutMs = _timeout) {
|
|
113
|
+
const s = String(text || "").trim();
|
|
114
|
+
if (!s || !remoteEmbedAvailable()) return null;
|
|
115
|
+
for (let attempt = 0; attempt <= BACKOFF_MS.length; attempt++) {
|
|
116
|
+
try {
|
|
117
|
+
const [v] = await callOnce(s.slice(0, 6000) || " ", timeoutMs);
|
|
118
|
+
if (Array.isArray(v) && v.length) {
|
|
119
|
+
reset();
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
trip("empty embedding");
|
|
123
|
+
return null;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const retryable = e?.retryable || e?.name === "AbortError";
|
|
126
|
+
if (retryable && attempt < BACKOFF_MS.length) {
|
|
127
|
+
await sleep(BACKOFF_MS[attempt]);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
trip(`${e?.status || e?.name || "error"}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 批量 embedding。返回与 texts 等长的数组(失败的槽位是 null)。
|
|
139
|
+
* 批内失败会自动拆成单条重试,尽力保住能拿到的向量。
|
|
140
|
+
*
|
|
141
|
+
* @param {string[]} texts
|
|
142
|
+
* @param {{timeoutMs?: number, batchSize?: number, concurrency?: number, onProgress?: Function}} [opts]
|
|
143
|
+
* @returns {Promise<Array<number[]|null>>}
|
|
144
|
+
*/
|
|
145
|
+
export async function remoteEmbedBatch(texts, opts = {}) {
|
|
146
|
+
const list = Array.isArray(texts) ? texts : [];
|
|
147
|
+
const out = new Array(list.length).fill(null);
|
|
148
|
+
if (!list.length || !remoteEmbedAvailable()) return out;
|
|
149
|
+
|
|
150
|
+
const timeoutMs = Number(opts.timeoutMs) || _timeout;
|
|
151
|
+
const batchSize = Math.min(Math.max(Number(opts.batchSize) || DEFAULT_BATCH, 1), 16);
|
|
152
|
+
const concurrency = Math.min(Math.max(Number(opts.concurrency) || DEFAULT_CONCURRENCY, 1), 4);
|
|
153
|
+
|
|
154
|
+
const batches = [];
|
|
155
|
+
for (let i = 0; i < list.length; i += batchSize) {
|
|
156
|
+
batches.push({ start: i, items: list.slice(i, i + batchSize) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
const worker = async () => {
|
|
161
|
+
while (cursor < batches.length) {
|
|
162
|
+
const b = batches[cursor++];
|
|
163
|
+
if (!remoteEmbedAvailable()) return; // 熔断后立刻停手
|
|
164
|
+
const payload = b.items.map((t) => String(t || "").trim().slice(0, 6000) || " ");
|
|
165
|
+
try {
|
|
166
|
+
const vecs = await callOnce(payload, timeoutMs);
|
|
167
|
+
reset();
|
|
168
|
+
for (let k = 0; k < vecs.length && k < b.items.length; k++) {
|
|
169
|
+
if (vecs[k]) out[b.start + k] = vecs[k];
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
trip(`${e?.status || e?.name || "error"}`);
|
|
173
|
+
// 批失败拆单条,尽量保住部分向量
|
|
174
|
+
for (let k = 0; k < payload.length; k++) {
|
|
175
|
+
if (!remoteEmbedAvailable()) break;
|
|
176
|
+
const v = await remoteEmbed(payload[k], timeoutMs);
|
|
177
|
+
if (v) out[b.start + k] = v;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (typeof opts.onProgress === "function") {
|
|
181
|
+
try {
|
|
182
|
+
opts.onProgress(out.filter(Boolean).length, list.length);
|
|
183
|
+
} catch {
|
|
184
|
+
/* 进度回调失败不影响索引 */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await sleep(BATCH_GAP_MS);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
192
|
+
return out;
|
|
193
|
+
}
|