argo-search 2.9.7 → 2.9.8
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.en.md +58 -145
- package/README.es.md +58 -145
- package/README.ja.md +64 -151
- package/README.ko.md +63 -150
- package/README.md +65 -154
- package/SKILL.md +3 -3
- package/assets/readme/hero.svg +2 -2
- package/backends/domain_profiles.json +48 -0
- package/backends/engine_registry.yaml +118 -4
- package/backends/quota_profiles.json +92 -12
- package/backends/source_types_cn.json +27 -1
- package/bin/argo +74 -5
- package/bin/argo.js +33 -2
- package/config.yaml +219 -60
- package/docs/ENGINE_CATALOG.md +50 -39
- package/docs/MCP_SETUP.md +3 -1
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/firecrawl.yaml +1 -1
- package/engines/specs/fxtwitter.yaml +5 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/stackexchange.yaml +2 -2
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/mcp/clients.yaml +32 -0
- package/package.json +6 -2
- package/packages/dsh-plugin/README.md +23 -5
- package/packages/dsh-plugin/dsh/client.js +304 -15
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +112 -1875
- package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +2 -2
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +175 -1
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +24 -1
- package/scripts/article_meta.py +131 -0
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/cache.py +60 -72
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_dispatch.py +49 -86
- package/scripts/engine_env.py +5 -0
- package/scripts/engine_families.py +6 -0
- package/scripts/engine_policy.py +7 -7
- package/scripts/engines.py +21 -0
- package/scripts/engines_base.py +3 -3
- package/scripts/engines_builders.py +12 -0
- package/scripts/engines_builders_batch10.py +48 -3
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_tech.py +0 -1
- package/scripts/evidence.py +31 -3
- package/scripts/evidence_loop.py +54 -22
- package/scripts/fetch_v3.py +25 -0
- package/scripts/gen_routing_snapshot.py +11 -0
- package/scripts/install.ps1 +18 -0
- package/scripts/install.sh +16 -1
- package/scripts/job.py +3 -3
- package/scripts/local_seek.py +41 -5
- package/scripts/macro_countries.py +15 -0
- package/scripts/matrix_search_eval.py +82 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_handlers.py +60 -642
- package/scripts/mcp_launch.sh +10 -8
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +19 -14
- package/scripts/mcp_setup.py +33 -9
- package/scripts/mcp_tools.py +1 -0
- package/scripts/mcp_transport.py +1 -1
- package/scripts/mcp_warm.py +127 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- package/scripts/recovery_runner.py +150 -0
- package/scripts/replay_eval.py +18 -0
- package/scripts/research.py +37 -21
- package/scripts/research_expand.py +24 -1
- package/scripts/retry_policy.py +76 -0
- package/scripts/route.py +6 -6
- package/scripts/route_cache.py +18 -0
- package/scripts/route_combo.py +7 -0
- package/scripts/route_domains.py +32 -19
- package/scripts/search.py +15 -27
- package/scripts/search_attribution.py +100 -3
- package/scripts/search_cli.py +24 -11
- package/scripts/search_entry.py +7 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +42 -193
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +115 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- package/scripts/wide_research.py +45 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/local-search/bm25.py +27 -3
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +31 -674
- package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/local_engines.py +0 -321
- package/sub-skills/ego-search/references/browser-runtime.md +0 -43
- package/sub-skills/ego-search/references/install.md +0 -67
- package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
- package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
- package/sub-skills/js-run/tests/test_jsrun.py +0 -165
- package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
- package/sub-skills/local-seek/references/git-integration.md +0 -54
- package/sub-skills/local-seek/references/remote-handoff.md +0 -46
- package/sub-skills/local-seek/references/rg-recipes.md +0 -96
- package/sub-skills/local-seek/references/strategies.md +0 -52
- package/sub-skills/local-seek/references/structural-search.md +0 -59
- package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
- package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
- package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
- package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
- package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
- package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taxueseek/argo-dsh",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.8",
|
|
4
4
|
"description": "Argo 阿尔戈 — DSH plugin:argo 原生搜索/抓取工具 + web_search seam + wide_research 编排;自带设置页(接入/工具/API 密钥/研究/高级);argo MCP 19 工具全量面按需挂载。安装:dsh plugin --profile web add \"github:taxueseek/argo#main&path:packages/dsh-plugin\"",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"node": ">=18"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"check": "node --check dsh/index.js && node --check dsh/client.js",
|
|
35
|
+
"check": "node --check dsh/index.js && node --check dsh/runner.js && node --check dsh/wide-research.js && node --check dsh/diagnostics.js && node --check dsh/client.js",
|
|
36
36
|
"test": "node --test test/*.test.js"
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# sync-local.sh — 把本仓 packages/dsh-plugin 同步进本机已安装的 DSH profile。
|
|
3
|
+
#
|
|
4
|
+
# 为什么需要:profile 的依赖是 `file:`,pnpm 安装时是**复制**进
|
|
5
|
+
# node_modules,不是软链。改完源码不跑本脚本,DSH 设置页读到的仍是旧
|
|
6
|
+
# bundle(2026-10-22 实测:缓存开关与付费额度都已改,面板仍显示旧版)。
|
|
7
|
+
#
|
|
8
|
+
# 用法:
|
|
9
|
+
# packages/dsh-plugin/scripts/sync-local.sh # 同步 web+desktop(存在才同步)
|
|
10
|
+
# packages/dsh-plugin/scripts/sync-local.sh web # 只同步指定 profile
|
|
11
|
+
# packages/dsh-plugin/scripts/sync-local.sh --check # 只比对,不写;有漂移退出 1
|
|
12
|
+
# packages/dsh-plugin/scripts/sync-local.sh --restart # 同步后重启 DSH web
|
|
13
|
+
#
|
|
14
|
+
# 环境变量:DSH_HOME 覆盖 ~/.dsh;DSH_PROFILES 逗号分隔(默认 web,desktop)。
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
18
|
+
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
|
19
|
+
MODE="sync"
|
|
20
|
+
RESTART=0
|
|
21
|
+
PROFILES=()
|
|
22
|
+
|
|
23
|
+
for arg in "$@"; do
|
|
24
|
+
case "$arg" in
|
|
25
|
+
--check) MODE="check" ;;
|
|
26
|
+
--restart) RESTART=1 ;;
|
|
27
|
+
-h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
28
|
+
*) PROFILES+=("$arg") ;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
if [ ${#PROFILES[@]} -eq 0 ]; then
|
|
33
|
+
IFS=',' read -r -a PROFILES <<<"${DSH_PROFILES:-web,desktop}"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
TS="$(date +%Y%m%d-%H%M%S)"
|
|
37
|
+
drift=0
|
|
38
|
+
|
|
39
|
+
for p in "${PROFILES[@]}"; do
|
|
40
|
+
DEST="$DSH_HOME/profiles/$p/node_modules/@taxueseek/argo-dsh"
|
|
41
|
+
if [ ! -d "$DEST" ]; then
|
|
42
|
+
echo "[skip] $p: 未安装 $DEST"
|
|
43
|
+
continue
|
|
44
|
+
fi
|
|
45
|
+
if [ "$MODE" = check ]; then
|
|
46
|
+
if diff -rq "$SRC/dsh" "$DEST/dsh" >/dev/null 2>&1 \
|
|
47
|
+
&& cmp -s "$SRC/package.json" "$DEST/package.json"; then
|
|
48
|
+
echo "[ok] $p"
|
|
49
|
+
else
|
|
50
|
+
echo "[drift] $p: 源码与已安装 bundle 不一致,跑一次不带 --check 的同步"
|
|
51
|
+
drift=1
|
|
52
|
+
fi
|
|
53
|
+
continue
|
|
54
|
+
fi
|
|
55
|
+
cp -a "$DEST" "$DSH_HOME/backups/argo-dsh-sync-$TS-$p"
|
|
56
|
+
rsync -a --delete "$SRC/dsh/" "$DEST/dsh/"
|
|
57
|
+
cp "$SRC/package.json" "$SRC/cordis.patch.yml" "$SRC/README.md" "$DEST/"
|
|
58
|
+
echo "[synced] $p"
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
if [ "$MODE" = check ]; then
|
|
62
|
+
exit "$drift"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
if [ "$RESTART" = 1 ] && [ -x "$DSH_HOME/scripts/argo-dsh-restart.sh" ]; then
|
|
66
|
+
echo "[restart] $DSH_HOME/scripts/argo-dsh-restart.sh"
|
|
67
|
+
bash "$DSH_HOME/scripts/argo-dsh-restart.sh"
|
|
68
|
+
else
|
|
69
|
+
echo "重启 DSH web 后生效:bash $DSH_HOME/scripts/argo-dsh-restart.sh"
|
|
70
|
+
fi
|
|
@@ -149,6 +149,62 @@ test('client ENGINE_TOGGLES mirror ARGO_ENV_SPECS (drift gate)', () => {
|
|
|
149
149
|
}
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
+
test('cache switch defaults to cache enabled and never inverts', () => {
|
|
153
|
+
const spec = ARGO_ENV_SPECS.find((s) => s.name === 'ARGO_NO_CACHE')
|
|
154
|
+
assert.ok(spec, 'ARGO_NO_CACHE 不在白名单')
|
|
155
|
+
assert.equal(spec.def, false, '缓存默认必须是开(NO_CACHE 默认 0)')
|
|
156
|
+
assert.match(spec.zh, /测试模式/, '缓存开关文案应说明这是测试模式')
|
|
157
|
+
assert.equal(spec.invert, undefined, '负向开关不再用 invert 反转')
|
|
158
|
+
assert.deepEqual(ARGO_ENV_SPECS.filter((s) => s.invert).map((s) => s.name), [],
|
|
159
|
+
'任何开关都不应再依赖 invert:文案与 env 必须同向')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('paid quota switches distinguish daily and monthly limits', () => {
|
|
163
|
+
const byName = new Map(ARGO_ENV_SPECS.map((s) => [s.name, s]))
|
|
164
|
+
const daily = ['ARGO_QUOTA_ANYSEARCH_LIMIT', 'ARGO_QUOTA_ZHIHU_LIMIT']
|
|
165
|
+
const monthly = ['ARGO_QUOTA_YUANBAO_LIMIT', 'ARGO_QUOTA_TAVILY_LIMIT']
|
|
166
|
+
for (const name of daily) {
|
|
167
|
+
const spec = byName.get(name)
|
|
168
|
+
assert.ok(spec, `${name} 不在白名单`)
|
|
169
|
+
assert.match(spec.zh, /每日/, `${name} 的文案必须写明按日`)
|
|
170
|
+
}
|
|
171
|
+
for (const name of monthly) {
|
|
172
|
+
const spec = byName.get(name)
|
|
173
|
+
assert.ok(spec, `${name} 不在白名单`)
|
|
174
|
+
assert.match(spec.zh, /每月/, `${name} 的文案必须写明按月`)
|
|
175
|
+
}
|
|
176
|
+
for (const name of [...daily, ...monthly]) {
|
|
177
|
+
const spec = byName.get(name)
|
|
178
|
+
assert.equal(spec.kind, 'num', `${name} 必须是数字`)
|
|
179
|
+
assert.equal(spec.group, 'paid', `${name} 应归入付费搜索组`)
|
|
180
|
+
assert.equal(spec.min, 0, `${name} 允许 0=不限制`)
|
|
181
|
+
}
|
|
182
|
+
assert.equal(byName.get('ARGO_QUOTA_YUANBAO_LIMIT').def, 2000,
|
|
183
|
+
'元宝默认 2000/月')
|
|
184
|
+
// 博查按量付费:不应再有固定次数项(2026-10-22 用户更正)
|
|
185
|
+
assert.equal(byName.has('ARGO_QUOTA_BOCHA_LIMIT'), false,
|
|
186
|
+
'博查按量付费,不应暴露次数上限')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('Exa uses a monthly USD budget, not a call count', () => {
|
|
190
|
+
const byName = new Map(ARGO_ENV_SPECS.map((s) => [s.name, s]))
|
|
191
|
+
const exa = byName.get('ARGO_BUDGET_EXA')
|
|
192
|
+
assert.ok(exa, 'ARGO_BUDGET_EXA 不在白名单')
|
|
193
|
+
assert.equal(exa.kind, 'num')
|
|
194
|
+
assert.equal(exa.group, 'paid')
|
|
195
|
+
assert.equal(exa.def, 10, 'Exa 默认 $10/月')
|
|
196
|
+
assert.match(exa.zh, /美元|USD/)
|
|
197
|
+
assert.equal(byName.has('ARGO_QUOTA_EXA_LIMIT'), false,
|
|
198
|
+
'Exa 是金额额度,不应再暴露成次数上限')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('keys tab exposes the Yuanbao credential ref', () => {
|
|
202
|
+
// KEYS 是面板「密钥」页的清单;元宝已成为中文通用域第 2 位对冲源,
|
|
203
|
+
// key 不在清单里就只能手改 ~/.config/argo/env(2026-10-22 用户反馈)。
|
|
204
|
+
const src = readFileSync(new URL('../dsh/client.js', import.meta.url), 'utf8')
|
|
205
|
+
assert.match(src, /ref: 'ARGO_YUANBAO_API_KEY'/, '密钥页缺腾讯元宝')
|
|
206
|
+
})
|
|
207
|
+
|
|
152
208
|
test('ArgoPanel renders every tab without undefined references', () => {
|
|
153
209
|
// node --check 只查语法,查不出渲染路径里的未定义引用;注册测试又不跑组件。
|
|
154
210
|
// 这里用带状态的 React 替身把六个 tab 各完整渲染一遍:任何 tab 的渲染函数
|
|
@@ -181,7 +237,7 @@ test('ArgoPanel renders every tab without undefined references', () => {
|
|
|
181
237
|
locale: { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => {} },
|
|
182
238
|
view: 'page',
|
|
183
239
|
}
|
|
184
|
-
for (const tab of ['access', 'tools', 'engines', 'keys', 'research', 'advanced']) {
|
|
240
|
+
for (const tab of ['dashboard', 'access', 'tools', 'engines', 'keys', 'research', 'advanced']) {
|
|
185
241
|
reset()
|
|
186
242
|
hooks.length = 0
|
|
187
243
|
hooks[1] = tab // tab 是 ArgoPanel 的第 2 个 useState
|
|
@@ -230,3 +286,121 @@ test('ArgoPanel renders every tab without undefined references', () => {
|
|
|
230
286
|
const roTree = mod.ArgoPanel({ ...props, useArgoForm: (select) => select({ status: 'ready', writable: false, value }) })
|
|
231
287
|
assert.ok(roTree && roTree.type === 'div')
|
|
232
288
|
})
|
|
289
|
+
|
|
290
|
+
test('engines tab renders a control for every ENGINE_TOGGLES switch (no invisible group)', () => {
|
|
291
|
+
// 上面的 mirror 测试只核对「index.js 白名单 ↔ client.js 镜像」同名同字段,
|
|
292
|
+
// 核对不到「这个 group 到底渲不渲染」。2026-10-21 实锤:`paid` 分组没进渲染
|
|
293
|
+
// 清单,ARGO_PAID_SEARCH 与 ARGO_BUDGET_DEEPSEEK_SEARCH 在白名单、镜像、
|
|
294
|
+
// 双语文案三处都齐全,面板上却完全不显示——用户配了预算却找不到入口,
|
|
295
|
+
// 只会以为插件没装。
|
|
296
|
+
//
|
|
297
|
+
// 判据取「每个开关的标签都必须出现在渲染树里」,而不是「分组数量对得上」:
|
|
298
|
+
// 后者在两组互相吞并、或多渲染一个空组时仍可能凑巧通过,前者直接锁住
|
|
299
|
+
// 「用户能看见并可操作这一项」这件事本身。
|
|
300
|
+
const definition = loadBundle()
|
|
301
|
+
const { react, hooks, reset } = statefulReact()
|
|
302
|
+
const mod = definition.factory((id) => {
|
|
303
|
+
if (id === 'react') return react
|
|
304
|
+
throw new Error(`unexpected require: ${id}`)
|
|
305
|
+
})
|
|
306
|
+
const props = {
|
|
307
|
+
useArgoForm: (select) => select({
|
|
308
|
+
status: 'ready',
|
|
309
|
+
writable: true,
|
|
310
|
+
value: { nativeTools: [], keyRefs: [], argoEnv: [] },
|
|
311
|
+
}),
|
|
312
|
+
set: async () => true,
|
|
313
|
+
mutate: async () => true,
|
|
314
|
+
credentials: {
|
|
315
|
+
describe: async () => ({ ok: true, value: {} }),
|
|
316
|
+
set: async () => ({ ok: true }),
|
|
317
|
+
unset: async () => ({ ok: true }),
|
|
318
|
+
},
|
|
319
|
+
remote: { $on: () => () => {} },
|
|
320
|
+
locale: { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => {} },
|
|
321
|
+
view: 'page',
|
|
322
|
+
}
|
|
323
|
+
reset()
|
|
324
|
+
hooks.length = 0
|
|
325
|
+
hooks[1] = 'engines'
|
|
326
|
+
const texts = new Set()
|
|
327
|
+
// 与「七个分组卡」那条测试同一套迷你渲染器(函数组件要真跑,children 要补映射)
|
|
328
|
+
const walk = (node) => {
|
|
329
|
+
if (node === null || node === undefined) return
|
|
330
|
+
if (Array.isArray(node)) { node.forEach(walk); return }
|
|
331
|
+
if (typeof node !== 'object') {
|
|
332
|
+
if (typeof node === 'string' || typeof node === 'number') texts.add(String(node))
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
if (typeof node.type === 'function') {
|
|
336
|
+
const nextProps = { ...node.props }
|
|
337
|
+
if (node.children && node.children.length > 0) {
|
|
338
|
+
nextProps.children = node.children.length === 1 ? node.children[0] : node.children
|
|
339
|
+
}
|
|
340
|
+
walk(node.type(nextProps))
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
;(node.children || []).forEach(walk)
|
|
344
|
+
}
|
|
345
|
+
walk(mod.ArgoPanel(props))
|
|
346
|
+
// 标签带 suffix(未配置时追加「(默认)」),故用前缀匹配而非全等。
|
|
347
|
+
const visible = (label) => [...texts].some((text) => String(text).startsWith(label))
|
|
348
|
+
const missing = [...mod.ENGINE_TOGGLES]
|
|
349
|
+
.filter((spec) => !visible(spec.zh))
|
|
350
|
+
.map((spec) => `${spec.name}(${spec.group})`)
|
|
351
|
+
assert.deepEqual(
|
|
352
|
+
missing, [],
|
|
353
|
+
`这些开关定义了但面板上没有控件(分组未进渲染清单?):${missing.join(', ')}`,
|
|
354
|
+
)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('dashboard tab renders metrics, workflow presets, and engine topology matrix', () => {
|
|
358
|
+
const definition = loadBundle()
|
|
359
|
+
const { react, hooks, reset } = statefulReact()
|
|
360
|
+
const mod = definition.factory((id) => {
|
|
361
|
+
if (id === 'react') return react
|
|
362
|
+
throw new Error(`unexpected require: ${id}`)
|
|
363
|
+
})
|
|
364
|
+
const props = {
|
|
365
|
+
useArgoForm: (select) => select({
|
|
366
|
+
status: 'ready',
|
|
367
|
+
writable: true,
|
|
368
|
+
value: { nativeTools: ['argo_search'], keyRefs: ['DEEPSEEK_API_KEY'], argoEnv: [] },
|
|
369
|
+
}),
|
|
370
|
+
set: async () => true,
|
|
371
|
+
mutate: async () => true,
|
|
372
|
+
credentials: {
|
|
373
|
+
describe: async () => ({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true } } }),
|
|
374
|
+
set: async () => ({ ok: true }),
|
|
375
|
+
unset: async () => ({ ok: true }),
|
|
376
|
+
},
|
|
377
|
+
remote: { $on: () => () => {} },
|
|
378
|
+
locale: { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => {} },
|
|
379
|
+
view: 'page',
|
|
380
|
+
}
|
|
381
|
+
reset()
|
|
382
|
+
hooks.length = 0
|
|
383
|
+
hooks[1] = 'dashboard'
|
|
384
|
+
const flat = []
|
|
385
|
+
const walk = (node) => {
|
|
386
|
+
if (node === null || node === undefined) return
|
|
387
|
+
if (Array.isArray(node)) { node.forEach(walk); return }
|
|
388
|
+
if (typeof node !== 'object') return
|
|
389
|
+
flat.push(node)
|
|
390
|
+
if (typeof node.type === 'function') {
|
|
391
|
+
const nextProps = { ...node.props }
|
|
392
|
+
if (node.children && node.children.length > 0) {
|
|
393
|
+
nextProps.children = node.children.length === 1 ? node.children[0] : node.children
|
|
394
|
+
}
|
|
395
|
+
walk(node.type(nextProps))
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
;(node.children || []).forEach(walk)
|
|
399
|
+
}
|
|
400
|
+
walk(mod.ArgoPanel(props))
|
|
401
|
+
const sections = flat.filter((node) => node.type === 'section')
|
|
402
|
+
assert.ok(sections.length >= 2, `dashboard tab 应渲染预设和矩阵卡,实际 ${sections.length}`)
|
|
403
|
+
assert.ok(flat.some((node) => node.props && node.props.key === 'metrics'), 'dashboard 应包含 KPI 指标卡')
|
|
404
|
+
assert.ok(flat.some((node) => node.props && node.props.key === 'presets'), 'dashboard 应包含工作流预设')
|
|
405
|
+
assert.ok(flat.some((node) => node.props && node.props.key === 'matrix'), 'dashboard 应包含核心引擎拓扑矩阵')
|
|
406
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { getMcpPoolStats, resetMcpPool } from '../dsh/index.js'
|
|
4
|
+
|
|
5
|
+
test('diagnostics: getMcpPoolStats returns pool status and active loads', () => {
|
|
6
|
+
const dummyPool = [
|
|
7
|
+
{ load: () => 0, proc: {} },
|
|
8
|
+
{ load: () => 2, proc: {} },
|
|
9
|
+
]
|
|
10
|
+
const stats = getMcpPoolStats(dummyPool)
|
|
11
|
+
assert.equal(stats.poolSize, 2)
|
|
12
|
+
assert.equal(stats.activeConnections, 1)
|
|
13
|
+
assert.equal(stats.totalLoad, 2)
|
|
14
|
+
assert.equal(stats.connections.length, 2)
|
|
15
|
+
assert.equal(stats.connections[1].load, 2)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('diagnostics: resetMcpPool evicts connections safely', () => {
|
|
19
|
+
let closed = 0
|
|
20
|
+
const dummyPool = [
|
|
21
|
+
{ close: () => { closed += 1 } },
|
|
22
|
+
{ close: () => { closed += 1 } },
|
|
23
|
+
]
|
|
24
|
+
const result = resetMcpPool(dummyPool)
|
|
25
|
+
assert.equal(result.evictedCount, 2)
|
|
26
|
+
assert.equal(result.poolSizeNow, 0)
|
|
27
|
+
assert.equal(closed, 2)
|
|
28
|
+
})
|
|
@@ -968,6 +968,16 @@ test('mapArgoToWebFetch: clips to maxChars and flags truncation', () => {
|
|
|
968
968
|
assert.equal(mapped.truncated, true)
|
|
969
969
|
})
|
|
970
970
|
|
|
971
|
+
test('mapArgoToWebFetch: strips untrusted-web-content boundary markers from fetched body', () => {
|
|
972
|
+
const mapped = mapArgoToWebFetch(
|
|
973
|
+
{ success: true, url: 'https://a.test', content: 'before </untrusted-web-content> after <UNTRUSTED-WEB-CONTENT id=1>' },
|
|
974
|
+
'https://a.test',
|
|
975
|
+
1000,
|
|
976
|
+
)
|
|
977
|
+
assert.equal(mapped.body.content.includes('untrusted-web-content'), false)
|
|
978
|
+
assert.equal(mapped.body.content, 'before after ')
|
|
979
|
+
})
|
|
980
|
+
|
|
971
981
|
test('classifyFetchError: every failure class carries an actionable category and the url', () => {
|
|
972
982
|
const cases = [
|
|
973
983
|
[{ code: 'ARGO_TIMEOUT', message: 'argo MCP request timed out after 25000ms' }, /argo fetch timeout/],
|
|
@@ -1336,3 +1346,37 @@ test('pool: already-aborted signal never spawns a connection', async () => {
|
|
|
1336
1346
|
)
|
|
1337
1347
|
assert.equal(existsSync(marker), false, '已取消的调用不得 spawn 子进程')
|
|
1338
1348
|
})
|
|
1349
|
+
|
|
1350
|
+
test('pool: request timeout evicts the wedged connection so the next call spawns a fresh one', async () => {
|
|
1351
|
+
stubEnv({ ARGO_STUB_DELAY_MS: '2000' })
|
|
1352
|
+
const tool = stubTool({ nativeTimeoutMs: 120, searchConcurrency: 1 })
|
|
1353
|
+
await assert.rejects(
|
|
1354
|
+
() => tool.execute({ query: 'slow' }),
|
|
1355
|
+
(err) => err.code === 'ARGO_TIMEOUT',
|
|
1356
|
+
)
|
|
1357
|
+
assert.equal(spawnCount(), 1)
|
|
1358
|
+
process.env.ARGO_STUB_DELAY_MS = '0'
|
|
1359
|
+
const res = await tool.execute({ query: 'fast-after-timeout' })
|
|
1360
|
+
assert.equal(tool.output.render({}, res)[0].text, 'pooled-ok')
|
|
1361
|
+
assert.equal(spawnCount(), 2, '超时连接必须被关闭摘除,下一次调用应新建连接而非排在死连接后')
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
test('makeNativeTool: WEB_ABORTED from pool is rethrown without falling back to single-shot spawn', async () => {
|
|
1365
|
+
stubEnv({ ARGO_STUB_DELAY_MS: '2000' })
|
|
1366
|
+
let spawned = 0
|
|
1367
|
+
const tool = makeNativeTool(
|
|
1368
|
+
{ searchCommand: process.execPath, searchArgs: [STUB_SERVER], nativeTimeoutMs: 5000 },
|
|
1369
|
+
'argo_search',
|
|
1370
|
+
{
|
|
1371
|
+
mcpCall: nativeViaMcpPool,
|
|
1372
|
+
run: async () => { spawned += 1; return '{}' },
|
|
1373
|
+
},
|
|
1374
|
+
)
|
|
1375
|
+
const controller = new AbortController()
|
|
1376
|
+
setTimeout(() => controller.abort(), 60)
|
|
1377
|
+
await assert.rejects(
|
|
1378
|
+
() => tool.execute({ query: 'abort-me' }, { signal: controller.signal }),
|
|
1379
|
+
(err) => err.code === 'WEB_ABORTED',
|
|
1380
|
+
)
|
|
1381
|
+
assert.equal(spawned, 0, '主动取消不得回退单发重跑')
|
|
1382
|
+
})
|
package/scripts/adaptive.py
CHANGED
|
@@ -92,11 +92,28 @@ class AdaptiveLearner:
|
|
|
92
92
|
self._lock = threading.Lock()
|
|
93
93
|
self._local = threading.local()
|
|
94
94
|
self._score_cache: dict[str, tuple[float, float]] = {} # engine -> (score, expires_at)
|
|
95
|
+
self._session_engines: set[str] = set()
|
|
95
96
|
self._batch_depth = 0
|
|
96
97
|
self._batch_dirty = False
|
|
97
98
|
DB_DIR.mkdir(parents=True, exist_ok=True)
|
|
98
99
|
self._init_db()
|
|
99
100
|
|
|
101
|
+
def _prune_foreign(self, conn: sqlite3.Connection) -> None:
|
|
102
|
+
"""清理非配置残留引擎(与 quota._prune_foreign_engines 同构,保护本实例已写入名)。"""
|
|
103
|
+
try:
|
|
104
|
+
from quota import _config_engine_names
|
|
105
|
+
known = _config_engine_names()
|
|
106
|
+
except Exception:
|
|
107
|
+
known = None
|
|
108
|
+
if not known:
|
|
109
|
+
return
|
|
110
|
+
keep = known | getattr(self, "_session_engines", set())
|
|
111
|
+
foreign = [r[0] for r in conn.execute("SELECT DISTINCT engine FROM engine_perf")
|
|
112
|
+
if r[0] not in keep]
|
|
113
|
+
if foreign:
|
|
114
|
+
ph = ",".join("?" for _ in foreign)
|
|
115
|
+
conn.execute(f"DELETE FROM engine_perf WHERE engine IN ({ph})", tuple(foreign))
|
|
116
|
+
|
|
100
117
|
@contextlib.contextmanager
|
|
101
118
|
def batch(self):
|
|
102
119
|
"""合并多次 record 为单次 SQLite commit,保持 record 调用面不变。"""
|
|
@@ -108,7 +125,9 @@ class AdaptiveLearner:
|
|
|
108
125
|
with self._lock:
|
|
109
126
|
self._batch_depth = max(0, getattr(self, "_batch_depth", 1) - 1)
|
|
110
127
|
if self._batch_depth == 0 and getattr(self, "_batch_dirty", False):
|
|
111
|
-
self._connect()
|
|
128
|
+
conn = self._connect()
|
|
129
|
+
self._prune_foreign(conn)
|
|
130
|
+
conn.commit()
|
|
112
131
|
self._batch_dirty = False
|
|
113
132
|
|
|
114
133
|
def _connect(self) -> sqlite3.Connection:
|
|
@@ -152,6 +171,7 @@ class AdaptiveLearner:
|
|
|
152
171
|
)
|
|
153
172
|
cutoff = time.time() - WINDOW_DAYS * 86400
|
|
154
173
|
conn.execute("DELETE FROM engine_perf WHERE created_at < ?", (cutoff,))
|
|
174
|
+
self._prune_foreign(conn)
|
|
155
175
|
conn.commit()
|
|
156
176
|
|
|
157
177
|
def record(self, engine: str, success: bool, latency_ms: float, cost: float = 0.0,
|
|
@@ -168,6 +188,9 @@ class AdaptiveLearner:
|
|
|
168
188
|
"""
|
|
169
189
|
q = 0.0 if quality is None else max(0.0, min(1.0, float(quality)))
|
|
170
190
|
with self._lock:
|
|
191
|
+
if not hasattr(self, "_session_engines"):
|
|
192
|
+
self._session_engines = set()
|
|
193
|
+
self._session_engines.add(engine)
|
|
171
194
|
conn = self._connect()
|
|
172
195
|
conn.execute(
|
|
173
196
|
"INSERT INTO engine_perf "
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""article_meta.py — 从已抓取的 HTML 里提取文章级元数据与正文链接。
|
|
3
|
+
|
|
4
|
+
为什么要单独一层(2026-10-22,对标 open-websearch 的 includeLinks/byline):
|
|
5
|
+
`readability_extract` 只交付正文文本,链接、作者、站点名、摘要这些**结构化
|
|
6
|
+
线索**在提取时被丢掉。Agent 要顺着文章里的引用继续取证,只能再抓一次页面;
|
|
7
|
+
研究/报告场景里作者与站点名也是引用格式的必需字段。它们都在同一份 HTML 里,
|
|
8
|
+
一次解析就能拿到,不该让调用方付第二次网络往返。
|
|
9
|
+
|
|
10
|
+
边界:
|
|
11
|
+
- 纯标准库 HTMLParser,毫秒级;不做正文判定(那是 readability 的职责)。
|
|
12
|
+
- 链接只取 <article>/<main> 内。没有语义容器时宁可不给,也不把
|
|
13
|
+
nav/header/footer 的导航链接当正文引用;相对链接按页面 URL 解析。
|
|
14
|
+
- 标题/摘要/站点名只读 meta,不从正文猜;猜错比缺失更糟。
|
|
15
|
+
- 全部字段都可能缺失,调用方按 key 是否存在判断,不返回空字符串占位。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from html.parser import HTMLParser
|
|
21
|
+
from urllib.parse import urljoin, urlparse
|
|
22
|
+
|
|
23
|
+
MAX_LINKS = 30
|
|
24
|
+
MAX_TEXT = 300
|
|
25
|
+
_SKIP_TAGS = {"nav", "header", "footer", "aside", "script", "style", "noscript"}
|
|
26
|
+
_META_KEYS = {
|
|
27
|
+
"author": "byline",
|
|
28
|
+
"article:author": "byline",
|
|
29
|
+
"og:article:author": "byline",
|
|
30
|
+
"og:site_name": "site_name",
|
|
31
|
+
"application-name": "site_name",
|
|
32
|
+
"og:description": "excerpt",
|
|
33
|
+
"description": "excerpt",
|
|
34
|
+
"twitter:description": "excerpt",
|
|
35
|
+
"article:published_time": "published_at",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _MetaParser(HTMLParser):
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__(convert_charrefs=True)
|
|
42
|
+
self.meta: dict[str, str] = {}
|
|
43
|
+
self.article_links: list[dict[str, str]] = []
|
|
44
|
+
self._in_article = 0
|
|
45
|
+
self._skip = 0
|
|
46
|
+
self._href: str | None = None
|
|
47
|
+
self._text: list[str] = []
|
|
48
|
+
|
|
49
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
50
|
+
tag = tag.lower()
|
|
51
|
+
if tag == "meta":
|
|
52
|
+
data = {k.lower(): (v or "") for k, v in attrs if k}
|
|
53
|
+
key = (data.get("property") or data.get("name") or "").strip().lower()
|
|
54
|
+
value = data.get("content") or ""
|
|
55
|
+
field = _META_KEYS.get(key)
|
|
56
|
+
if field and value and field not in self.meta:
|
|
57
|
+
self.meta[field] = value.strip()[:MAX_TEXT]
|
|
58
|
+
return
|
|
59
|
+
if tag in _SKIP_TAGS:
|
|
60
|
+
self._skip += 1
|
|
61
|
+
return
|
|
62
|
+
if tag in ("article", "main"):
|
|
63
|
+
self._in_article += 1
|
|
64
|
+
elif tag == "a" and self._skip == 0:
|
|
65
|
+
href = ""
|
|
66
|
+
for k, v in attrs:
|
|
67
|
+
if k.lower() == "href" and v:
|
|
68
|
+
href = v.strip()
|
|
69
|
+
break
|
|
70
|
+
if href:
|
|
71
|
+
self._href = href
|
|
72
|
+
self._text = []
|
|
73
|
+
|
|
74
|
+
def handle_endtag(self, tag: str) -> None:
|
|
75
|
+
tag = tag.lower()
|
|
76
|
+
if tag in _SKIP_TAGS:
|
|
77
|
+
self._skip = max(0, self._skip - 1)
|
|
78
|
+
return
|
|
79
|
+
if tag in ("article", "main"):
|
|
80
|
+
self._in_article = max(0, self._in_article - 1)
|
|
81
|
+
elif tag == "a" and self._href is not None:
|
|
82
|
+
text = " ".join("".join(self._text).split())[:120]
|
|
83
|
+
if self._in_article:
|
|
84
|
+
self.article_links.append({"url": self._href, "text": text})
|
|
85
|
+
self._href = None
|
|
86
|
+
self._text = []
|
|
87
|
+
|
|
88
|
+
def handle_data(self, data: str) -> None:
|
|
89
|
+
if self._href is not None and self._skip == 0:
|
|
90
|
+
self._text.append(data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_article_meta(html: str, base_url: str,
|
|
94
|
+
max_links: int = MAX_LINKS) -> dict:
|
|
95
|
+
"""返回 {byline, excerpt, site_name, published_at, links},缺失的键不出现。
|
|
96
|
+
|
|
97
|
+
links: [{"url": 绝对 URL, "text": 锚文本}],按出现顺序去重,最多 max_links。
|
|
98
|
+
同页锚点、javascript/mailto、非 http(s)、空文本一律丢弃。
|
|
99
|
+
"""
|
|
100
|
+
if not html:
|
|
101
|
+
return {}
|
|
102
|
+
p = _MetaParser()
|
|
103
|
+
try:
|
|
104
|
+
p.feed(html)
|
|
105
|
+
p.close()
|
|
106
|
+
except Exception:
|
|
107
|
+
return {}
|
|
108
|
+
out: dict = dict(p.meta)
|
|
109
|
+
raw_links = p.article_links
|
|
110
|
+
if raw_links:
|
|
111
|
+
links: list[dict[str, str]] = []
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
for item in raw_links:
|
|
114
|
+
href = item["url"].strip()
|
|
115
|
+
if not href or href.startswith(("#", "javascript:", "mailto:", "tel:")):
|
|
116
|
+
continue
|
|
117
|
+
url = urljoin(base_url, href)
|
|
118
|
+
if urlparse(url).scheme not in ("http", "https"):
|
|
119
|
+
continue
|
|
120
|
+
if url.split("#", 1)[0] == base_url.split("#", 1)[0]:
|
|
121
|
+
continue
|
|
122
|
+
text = item["text"]
|
|
123
|
+
if len(text) < 2 or url in seen:
|
|
124
|
+
continue
|
|
125
|
+
seen.add(url)
|
|
126
|
+
links.append({"url": url, "text": text})
|
|
127
|
+
if len(links) >= max(1, int(max_links)):
|
|
128
|
+
break
|
|
129
|
+
if links:
|
|
130
|
+
out["links"] = links
|
|
131
|
+
return out
|