argo-search 1.0.1 → 2.9.7
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 +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +564 -0
- package/docs/MCP_SETUP.md +106 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +31 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +271 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1779 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +184 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +488 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +95 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +402 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/search_v3.py +1182 -65
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp_handlers.py — MCP 工具执行逻辑(P2-4 拆分自 mcp_server.py)。
|
|
3
|
+
|
|
4
|
+
路径引导、延迟导入缓存、结果压缩、14 个工具的 execute_tool 分派、
|
|
5
|
+
后台预热(含 local-seek 模块导入预热,为进程内调用铺路)。
|
|
6
|
+
schema 来源在 mcp_tools.py;JSON-RPC 帧处理在 mcp_transport.py。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import importlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import cli_io
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
ARGO_DIR = os.path.dirname(SCRIPT_DIR) # argo 根目录
|
|
24
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
25
|
+
# 子技能目录,供 sub-skills/local-search/ 等模块本地导入。
|
|
26
|
+
# append 而非 insert(0):sub-skills 下的顶层模块(health_check 等)不得
|
|
27
|
+
# 劫持 scripts/ 下同名模块的解析(否则 route 的健康检查分支会静默漂移)。
|
|
28
|
+
SUB_SKILLS_DIR = os.path.join(ARGO_DIR, "sub-skills")
|
|
29
|
+
if os.path.isdir(SUB_SKILLS_DIR):
|
|
30
|
+
for sub in os.listdir(SUB_SKILLS_DIR):
|
|
31
|
+
sub_path = os.path.join(SUB_SKILLS_DIR, sub)
|
|
32
|
+
if os.path.isdir(sub_path) and sub_path not in sys.path:
|
|
33
|
+
sys.path.append(sub_path)
|
|
34
|
+
# 切换 CWD 到 argo 根目录,确保相对路径和子进程 work
|
|
35
|
+
os.chdir(ARGO_DIR)
|
|
36
|
+
|
|
37
|
+
# 启动日志(写入 stderr,不影响 stdio 通信)
|
|
38
|
+
sys.stderr.write("[argo-mcp] starting (lazy imports enabled)\n")
|
|
39
|
+
sys.stderr.flush()
|
|
40
|
+
|
|
41
|
+
_cache_instance = None
|
|
42
|
+
_warm_started = False
|
|
43
|
+
_module_cache: dict[str, Any] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_LOCAL_READ_ALLOWED_EXTS = {
|
|
49
|
+
".md", ".txt", ".csv", ".tsv", ".json", ".yaml", ".yml", ".log",
|
|
50
|
+
".py", ".js", ".ts", ".html", ".xml", ".rst", ".ipynb",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _local_read_whitelist() -> list[str]:
|
|
55
|
+
"""白名单目录:ARGO_LOCAL_READ_DIRS(逗号分隔);没配置就返回空,不放行。"""
|
|
56
|
+
raw = (os.environ.get("ARGO_LOCAL_READ_DIRS") or "").strip()
|
|
57
|
+
if not raw:
|
|
58
|
+
return []
|
|
59
|
+
return [os.path.realpath(os.path.expanduser(d))
|
|
60
|
+
for d in raw.split(",") if d.strip()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _local_read_preview(
|
|
64
|
+
path: str,
|
|
65
|
+
max_chars: int = 4000,
|
|
66
|
+
line_start: int | None = None,
|
|
67
|
+
line_end: int | None = None,
|
|
68
|
+
) -> tuple[str, dict[str, Any]]:
|
|
69
|
+
"""白名单内文本文件预览(行窗口或字符截断);越权/二进制拒绝。"""
|
|
70
|
+
root = os.path.realpath(os.path.expanduser(path))
|
|
71
|
+
allowed = _local_read_whitelist()
|
|
72
|
+
if not allowed:
|
|
73
|
+
raise PermissionError(
|
|
74
|
+
"本地读取未配置白名单:设 ARGO_LOCAL_READ_DIRS=目录1,目录2(没配置就不放行)"
|
|
75
|
+
)
|
|
76
|
+
if not any(root == d or root.startswith(d + os.sep) for d in allowed):
|
|
77
|
+
raise PermissionError(f"路径不在白名单目录内: {path}")
|
|
78
|
+
if not os.path.isfile(root):
|
|
79
|
+
raise FileNotFoundError(f"文件不存在: {path}")
|
|
80
|
+
ext = os.path.splitext(root)[1].lower()
|
|
81
|
+
if ext not in _LOCAL_READ_ALLOWED_EXTS:
|
|
82
|
+
raise PermissionError(f"仅支持文本类文件预览({ext} 不支持;pdf 走其他路径)")
|
|
83
|
+
size = os.path.getsize(root)
|
|
84
|
+
if size > 4 * 1024 * 1024:
|
|
85
|
+
raise PermissionError("文件超过 4MB,拒绝预览(请缩小范围或改用搜索)")
|
|
86
|
+
|
|
87
|
+
with open(root, encoding="utf-8", errors="replace") as f:
|
|
88
|
+
full = f.read()
|
|
89
|
+
# total_lines 语义是「整个文件的总行数」,必须从全文计——历史上曾从
|
|
90
|
+
# 切片/截断后的 content 数换行,行窗口分支会把「共 50 行」报成「共 3 行」。
|
|
91
|
+
# 注意尾巴:文件以换行结尾时不能再 +1,否则 50 行会被报成 51 行。
|
|
92
|
+
total_lines = full.count("\n") + (1 if full and not full.endswith("\n") else 0)
|
|
93
|
+
if line_start is not None or line_end is not None:
|
|
94
|
+
start = max(1, int(line_start or 1))
|
|
95
|
+
end = int(line_end) if line_end else start + 200
|
|
96
|
+
lines = full.splitlines(keepends=True)[(start - 1): end]
|
|
97
|
+
content = "".join(lines)
|
|
98
|
+
lines_slice = (start, start + len(lines) - 1)
|
|
99
|
+
else:
|
|
100
|
+
content = full
|
|
101
|
+
lines_slice = None
|
|
102
|
+
|
|
103
|
+
truncated = len(content) > max_chars
|
|
104
|
+
if truncated:
|
|
105
|
+
content = content[:max_chars]
|
|
106
|
+
return content, {
|
|
107
|
+
"path": root,
|
|
108
|
+
"chars": len(content),
|
|
109
|
+
"lines": (lines_slice[1] - lines_slice[0] + 1) if lines_slice else None,
|
|
110
|
+
"total_lines": total_lines,
|
|
111
|
+
"is_preview": truncated or lines_slice is not None,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _seek_py() -> str:
|
|
116
|
+
"""定位 local-seek 的 seek.py(安装感知、唯一来源,见 seek_locator)。
|
|
117
|
+
|
|
118
|
+
local-seek 已于 2026-08-05 收编为 argo 子技能,标准位置是打包的
|
|
119
|
+
SUB_SKILLS_DIR/local-seek;自定义/遗留位置由 ARGO_LOCAL_SEEK_PATH 或
|
|
120
|
+
ARGO_LOCAL_SEEK_ROOTS 承载(不再硬编码 ~/.agents/skills 等主机路径)。
|
|
121
|
+
委托 seek_locator,避免与 search.py 各自维护一套发现逻辑而漂移。"""
|
|
122
|
+
from seek_locator import resolve_seek_py # 延迟导入:mcp_handlers 常被早拉入
|
|
123
|
+
return resolve_seek_py()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _lazy_import(module_name: str):
|
|
127
|
+
"""延迟导入模块,首次调用时加载(进程内缓存)。"""
|
|
128
|
+
return importlib.import_module(module_name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _lazy_cached(module_name: str):
|
|
132
|
+
"""带进程内缓存的延迟导入,避免重复 importlib 开销。"""
|
|
133
|
+
m = _module_cache.get(module_name)
|
|
134
|
+
if m is None:
|
|
135
|
+
m = importlib.import_module(module_name)
|
|
136
|
+
_module_cache[module_name] = m
|
|
137
|
+
return m
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_cache():
|
|
141
|
+
global _cache_instance
|
|
142
|
+
if _cache_instance is None:
|
|
143
|
+
cache = _lazy_cached("cache")
|
|
144
|
+
_cache_instance = cache.SearchCache()
|
|
145
|
+
return _cache_instance
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _dumps(obj: Any, pretty: bool = False) -> str:
|
|
149
|
+
"""MCP 的 JSON 序列化:复用 CLI stdout 的同一套计算方式(cli_io 是唯一来源)。
|
|
150
|
+
|
|
151
|
+
两边此前各写一份实现(这里 separators=(",", ":")、CLI 侧各写 indent=2),
|
|
152
|
+
同一份载荷两套格式——计算方式分叉后没人会发现「MCP 紧凑、CLI 美化」这件事本身
|
|
153
|
+
就是不一致。合并后只留一个来源;`pretty=True` 仍供人读场景使用。
|
|
154
|
+
"""
|
|
155
|
+
return cli_io.dumps_pretty(obj) if pretty else cli_io.dumps(obj)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _err_is(message: str) -> dict[str, Any]:
|
|
159
|
+
"""工具级参数错误(isError 契约,与 _ok 对偶)。"""
|
|
160
|
+
return {"content": [{"type": "text", "text": _dumps({"error": message})}],
|
|
161
|
+
"isError": True}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ok(payload: Any, pretty: bool = False) -> dict[str, Any]:
|
|
165
|
+
return {"content": [{"type": "text", "text": _dumps(payload, pretty=pretty)}]}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _trim_snippet(text: Any, n: int = 120) -> str | None:
|
|
169
|
+
if not text:
|
|
170
|
+
return None
|
|
171
|
+
s = str(text)
|
|
172
|
+
return s if len(s) <= n else s[:n]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _compact_search_result(result: dict[str, Any], summary: bool = False) -> dict[str, Any]:
|
|
176
|
+
"""去掉 MCP 热路径用不到的重字段,缩小首包。"""
|
|
177
|
+
snip_n = 80 if summary else 160
|
|
178
|
+
out: dict[str, Any] = {
|
|
179
|
+
"query": result.get("query"),
|
|
180
|
+
"engine": result.get("engine"),
|
|
181
|
+
"engines_used": result.get("engines_used") or result.get("engines"),
|
|
182
|
+
"count": result.get("count"),
|
|
183
|
+
"elapsed_ms": result.get("elapsed_ms"),
|
|
184
|
+
"cached": result.get("cached"),
|
|
185
|
+
"mode": result.get("mode"),
|
|
186
|
+
"depth": result.get("depth"),
|
|
187
|
+
"execution_tier": result.get("execution_tier"),
|
|
188
|
+
"input_kind": result.get("input_kind"),
|
|
189
|
+
"login_hint": result.get("login_hint"),
|
|
190
|
+
}
|
|
191
|
+
results = []
|
|
192
|
+
for r in result.get("results") or []:
|
|
193
|
+
if not isinstance(r, dict):
|
|
194
|
+
continue
|
|
195
|
+
item = {
|
|
196
|
+
"title": r.get("title"),
|
|
197
|
+
"url": r.get("url"),
|
|
198
|
+
"snippet": _trim_snippet(r.get("snippet"), snip_n),
|
|
199
|
+
"source": r.get("source"),
|
|
200
|
+
"score": r.get("score"),
|
|
201
|
+
}
|
|
202
|
+
# 保留发布时间维度(仅引擎输出该字段时,避免空字段噪音)
|
|
203
|
+
if r.get("published_at"):
|
|
204
|
+
item["published_at"] = r["published_at"]
|
|
205
|
+
# 顶包标记:recovery 替换了引擎原结果时透传,供上层识别结果已被代答
|
|
206
|
+
if r.get("_recovered"):
|
|
207
|
+
item["recovered"] = r["_recovered"]
|
|
208
|
+
# 保留快评,丢掉大块 meta
|
|
209
|
+
for k in ("selection", "absorption", "credibility_fast", "evidence_flags"):
|
|
210
|
+
if k in r:
|
|
211
|
+
item[k] = r[k]
|
|
212
|
+
# 证据完整链路 P0:保留核验信号(仅存在时,避免空字段噪音)
|
|
213
|
+
for k in ("fetch_suggested", "has_fetched_evidence", "post_fetch_absorption"):
|
|
214
|
+
if r.get(k) is not None:
|
|
215
|
+
item[k] = r[k]
|
|
216
|
+
results.append(item)
|
|
217
|
+
out["results"] = results
|
|
218
|
+
# 证据完整链路 P0:顶层门控信号(轻量 4 字段,供 Agent 判断「能否下结论」)
|
|
219
|
+
if result.get("fetch_required") is not None:
|
|
220
|
+
out["fetch_required"] = result["fetch_required"]
|
|
221
|
+
el = result.get("evidence_loop")
|
|
222
|
+
if isinstance(el, dict):
|
|
223
|
+
out["evidence_loop"] = {
|
|
224
|
+
"suggested": el.get("suggested") or [],
|
|
225
|
+
"verified_count": el.get("verified_count") or 0,
|
|
226
|
+
"pending_count": el.get("pending_count") or 0,
|
|
227
|
+
}
|
|
228
|
+
# 引擎可观测性:精简版 engine_outcomes(engine/status/latency,去掉 detail)
|
|
229
|
+
# 让 Agent 能看到「哪个引擎失败、为什么类别」,不占大体积
|
|
230
|
+
outcomes = result.get("engine_outcomes") or []
|
|
231
|
+
if outcomes:
|
|
232
|
+
out["engine_outcomes"] = [
|
|
233
|
+
{
|
|
234
|
+
"engine": o.get("engine"),
|
|
235
|
+
"status": o.get("status"),
|
|
236
|
+
"latency_ms": o.get("latency_ms"),
|
|
237
|
+
"results_count": o.get("results_count"),
|
|
238
|
+
}
|
|
239
|
+
for o in outcomes
|
|
240
|
+
if isinstance(o, dict)
|
|
241
|
+
][:12]
|
|
242
|
+
wasted = result.get("wasted_engine_ms")
|
|
243
|
+
if wasted:
|
|
244
|
+
out["wasted_engine_ms"] = wasted
|
|
245
|
+
if result.get("early_stopped"):
|
|
246
|
+
out["early_stopped"] = True
|
|
247
|
+
if result.get("minhash_removed"):
|
|
248
|
+
out["minhash_removed"] = result.get("minhash_removed")
|
|
249
|
+
if result.get("domain_filter"):
|
|
250
|
+
out["domain_filter"] = result["domain_filter"]
|
|
251
|
+
if result.get("rank_method"):
|
|
252
|
+
out["rank_method"] = result.get("rank_method")
|
|
253
|
+
if result.get("reranker"):
|
|
254
|
+
out["reranker"] = result.get("reranker")
|
|
255
|
+
# sources 沉底同构(若已有)
|
|
256
|
+
sources = result.get("sources")
|
|
257
|
+
if sources:
|
|
258
|
+
out["sources"] = [
|
|
259
|
+
{
|
|
260
|
+
"ref": s.get("ref"),
|
|
261
|
+
"title": s.get("title"),
|
|
262
|
+
"url": s.get("url"),
|
|
263
|
+
"engine": s.get("engine") or s.get("source"),
|
|
264
|
+
}
|
|
265
|
+
for s in sources
|
|
266
|
+
if isinstance(s, dict) and s.get("url")
|
|
267
|
+
][:20]
|
|
268
|
+
if result.get("errors"):
|
|
269
|
+
out["errors"] = result["errors"]
|
|
270
|
+
if result.get("limitations"):
|
|
271
|
+
out["limitations"] = result["limitations"]
|
|
272
|
+
# 语言偏好观测(精简:去掉 habit_counts 明细,保留决策用字段)
|
|
273
|
+
lp = result.get("lang_pref")
|
|
274
|
+
if isinstance(lp, dict):
|
|
275
|
+
out["lang_pref"] = {
|
|
276
|
+
"query_lang": lp.get("query_lang"),
|
|
277
|
+
"system_lang": lp.get("system_lang"),
|
|
278
|
+
"habit_lang": lp.get("habit_lang"),
|
|
279
|
+
"prefer_langs": lp.get("prefer_langs"),
|
|
280
|
+
"engine_lang": lp.get("engine_lang"),
|
|
281
|
+
}
|
|
282
|
+
rec = result.get("recovery")
|
|
283
|
+
if isinstance(rec, dict) and rec.get("triggered"):
|
|
284
|
+
out["recovery"] = {
|
|
285
|
+
"recovered": rec.get("recovered"),
|
|
286
|
+
"level_used": rec.get("level_used"),
|
|
287
|
+
"strategy_used": rec.get("strategy_used"),
|
|
288
|
+
"note": rec.get("note"),
|
|
289
|
+
}
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _compact_research_result(report: dict[str, Any], summary: bool = False) -> dict[str, Any]:
|
|
294
|
+
"""研究包:只保留 Agent 写作需要的字段,砍掉子查询原始 dumps。"""
|
|
295
|
+
snip_n = 100 if summary else 160
|
|
296
|
+
keys = (
|
|
297
|
+
"query", "query_original", "execution_tier", "topic_profile", "topic_profile_key",
|
|
298
|
+
"discipline", "quality_gates", "report_sections", "source_grades", "disclaimer",
|
|
299
|
+
"academic_discipline", "engines_used", "source_distribution", "elapsed_ms",
|
|
300
|
+
"sub_query_count", "total_sources", "gaps", "mode", "sub_queries",
|
|
301
|
+
"engines_priority", "vertical_engines", "fetch_required",
|
|
302
|
+
"kind", "conclusion_cap", "quality_gate_results", "work_packages",
|
|
303
|
+
"work_package_stages", "protocol",
|
|
304
|
+
)
|
|
305
|
+
out: dict[str, Any] = {k: report[k] for k in keys if k in report and report[k] is not None}
|
|
306
|
+
|
|
307
|
+
# 证据完整链路 P0:研究包门控(pending_fetch 截断到 5 条,保留可编程核验信号)
|
|
308
|
+
el = report.get("evidence_loop")
|
|
309
|
+
if isinstance(el, dict):
|
|
310
|
+
out["evidence_loop"] = {
|
|
311
|
+
"high_consequence_domain": el.get("high_consequence_domain"),
|
|
312
|
+
"pending_fetch": (el.get("pending_fetch") or [])[:5],
|
|
313
|
+
"verified_count": el.get("verified_count") or 0,
|
|
314
|
+
"pending_count": el.get("pending_count") or 0,
|
|
315
|
+
}
|
|
316
|
+
if isinstance(report.get("verify"), dict):
|
|
317
|
+
v = report["verify"]
|
|
318
|
+
out["verify"] = {
|
|
319
|
+
"revision_summary": v.get("revision_summary") or {},
|
|
320
|
+
"pending": (v.get("pending") or [])[:5],
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
findings = []
|
|
324
|
+
for kf in report.get("key_findings") or []:
|
|
325
|
+
if not isinstance(kf, dict):
|
|
326
|
+
continue
|
|
327
|
+
top = dict(kf.get("top_result") or {})
|
|
328
|
+
if top.get("snippet"):
|
|
329
|
+
top["snippet"] = _trim_snippet(top.get("snippet"), snip_n)
|
|
330
|
+
findings.append({
|
|
331
|
+
"aspect": kf.get("aspect"),
|
|
332
|
+
"strategy": kf.get("strategy"),
|
|
333
|
+
"top_result": top,
|
|
334
|
+
"result_count": kf.get("result_count"),
|
|
335
|
+
"citation_refs": kf.get("citation_refs"),
|
|
336
|
+
})
|
|
337
|
+
out["key_findings"] = findings
|
|
338
|
+
|
|
339
|
+
sources = []
|
|
340
|
+
for s in report.get("sources") or report.get("citations") or []:
|
|
341
|
+
if not isinstance(s, dict) or not s.get("url"):
|
|
342
|
+
continue
|
|
343
|
+
sources.append({
|
|
344
|
+
"ref": s.get("ref") or s.get("id"),
|
|
345
|
+
"title": s.get("title"),
|
|
346
|
+
"url": s.get("url"),
|
|
347
|
+
"engine": s.get("engine") or s.get("source"),
|
|
348
|
+
"snippet": _trim_snippet(s.get("snippet"), snip_n),
|
|
349
|
+
})
|
|
350
|
+
if summary and len(sources) >= 10:
|
|
351
|
+
break
|
|
352
|
+
out["sources"] = sources
|
|
353
|
+
if not summary:
|
|
354
|
+
out["citations"] = report.get("citations") or sources
|
|
355
|
+
# 交叉引用截断
|
|
356
|
+
crs = report.get("cross_references") or []
|
|
357
|
+
out["cross_references"] = crs[:5]
|
|
358
|
+
# 覆盖度 / 验证记录 / 盲区(科研方法论增强)
|
|
359
|
+
if report.get("coverage_map"):
|
|
360
|
+
out["coverage_map"] = report["coverage_map"]
|
|
361
|
+
if report.get("verification_records"):
|
|
362
|
+
out["verification_records"] = report["verification_records"][:12]
|
|
363
|
+
if report.get("blind_spots"):
|
|
364
|
+
out["blind_spots"] = report["blind_spots"][:8]
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _prewarm_anysearch_connection() -> None:
|
|
369
|
+
"""在 warm 线程里预建 anysearch 的隧道连接(2026-10-18)。
|
|
370
|
+
|
|
371
|
+
为什么放在 MCP 而不是 CLI 启动:实测 cli 路径上没有任何窗口——引擎在
|
|
372
|
+
117–140ms 就借连接,而经代理的 TLS 握手要 320–970ms,预连接总是慢一步
|
|
373
|
+
(对拍见 docs-local;强行预连接反而多付一次握手)。MCP 是长驻进程,
|
|
374
|
+
initialize 之后的 warm 与用户第一次 tools/call 之间通常有整秒级间隔,
|
|
375
|
+
这批固定成本正好藏在里面。
|
|
376
|
+
|
|
377
|
+
只做 anysearch:它是 general 域主力**POST** 引擎,也是当前唯一每次都
|
|
378
|
+
走 _do_post 的域;失败静默(离线/代理不可用/池关闭都不影响服务)。
|
|
379
|
+
"""
|
|
380
|
+
if os.environ.get("ARGO_NO_PREWARM", "").strip() not in ("", "0"):
|
|
381
|
+
return
|
|
382
|
+
try:
|
|
383
|
+
import urllib.parse
|
|
384
|
+
from conn_pool import (give_back, key_for, stats as pool_stats)
|
|
385
|
+
from net_proxy import open_connection, resolve_proxy
|
|
386
|
+
if not pool_stats().get("enabled"):
|
|
387
|
+
return
|
|
388
|
+
url = "https://api.anysearch.com/mcp"
|
|
389
|
+
parsed = urllib.parse.urlparse(url)
|
|
390
|
+
proxy_url = resolve_proxy(url)
|
|
391
|
+
conn, _via = open_connection(parsed, 8.0, proxy_url)
|
|
392
|
+
# open_connection 返回的是**未连接**的 HTTPSConnection(懒连接),
|
|
393
|
+
# 必须显式 connect;否则还进池的只是个空壳,_sock_alive 会直接丢弃。
|
|
394
|
+
conn.connect()
|
|
395
|
+
give_back(key_for(parsed, proxy_url), conn)
|
|
396
|
+
except Exception as e:
|
|
397
|
+
sys.stderr.write(f"[argo-mcp] prewarm-skip: {type(e).__name__}: {e}\n")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _warm_core_async() -> None:
|
|
401
|
+
"""initialize 后后台预热 search+cache,摊平首次 tools/call 延迟。"""
|
|
402
|
+
global _warm_started
|
|
403
|
+
if _warm_started:
|
|
404
|
+
return
|
|
405
|
+
_warm_started = True
|
|
406
|
+
|
|
407
|
+
def _run() -> None:
|
|
408
|
+
t0 = __import__("time").time()
|
|
409
|
+
try:
|
|
410
|
+
_lazy_cached("search")
|
|
411
|
+
_get_cache()
|
|
412
|
+
# 路由域正则全量预热(2026-09-29):match_domains 懒编译后,长驻
|
|
413
|
+
# 进程的首次 search 仍要付「扫到域」的编译税;这里一次付清,首包
|
|
414
|
+
# tools/call 不再带路由冷启动。唯一来源在 route_domains——曾在此
|
|
415
|
+
# 内联复刻同一句,使 warm_compiled_domains 成了没人调的死函数。
|
|
416
|
+
try:
|
|
417
|
+
from route_domains import warm_compiled_domains
|
|
418
|
+
warm_compiled_domains()
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
# 连接预热排在模块预热之后:先把 search/route 的导入税付掉,
|
|
422
|
+
# 再付 TLS。两段都在同一个 daemon 线程里串行,互不抢占首包。
|
|
423
|
+
_prewarm_anysearch_connection()
|
|
424
|
+
ms = int((__import__("time").time() - t0) * 1000)
|
|
425
|
+
sys.stderr.write(f"[argo-mcp] warm-core ok {ms}ms\n")
|
|
426
|
+
except Exception as e:
|
|
427
|
+
sys.stderr.write(f"[argo-mcp] warm-core fail: {type(e).__name__}: {e}\n")
|
|
428
|
+
sys.stderr.flush()
|
|
429
|
+
|
|
430
|
+
threading.Thread(target=_run, name="argo-mcp-warm", daemon=True).start()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _resolve_research_profile(arguments: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
|
|
434
|
+
"""解析 topic profile;全部在 argo 内,不加载外部 skill。"""
|
|
435
|
+
try:
|
|
436
|
+
profiles = _lazy_cached("topic_research_profiles")
|
|
437
|
+
except Exception:
|
|
438
|
+
return None, None
|
|
439
|
+
topic = arguments.get("topic")
|
|
440
|
+
key = None
|
|
441
|
+
if topic:
|
|
442
|
+
prof = profiles.get_profile(str(topic))
|
|
443
|
+
if prof:
|
|
444
|
+
# 归一化 key
|
|
445
|
+
raw = str(topic).strip()
|
|
446
|
+
key = profiles.ALIASES.get(raw) or profiles.ALIASES.get(raw.lower()) or raw.lower()
|
|
447
|
+
return prof, key
|
|
448
|
+
return None, None
|
|
449
|
+
if arguments.get("auto_topic", True):
|
|
450
|
+
key = profiles.detect_topic_from_query(arguments.get("query") or "")
|
|
451
|
+
if key:
|
|
452
|
+
return profiles.get_profile(key), key
|
|
453
|
+
return None, None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _search_social_platforms(
|
|
457
|
+
platforms: list[str], query: str, n: int, timeout: float = 20.0,
|
|
458
|
+
) -> tuple[dict[str, list], list[str], list[str]]:
|
|
459
|
+
"""并行抓取多社交平台(MCP 热路径)。
|
|
460
|
+
|
|
461
|
+
timeout 秒后放弃未完成平台、返回已完成部分(不抛错),
|
|
462
|
+
避免单平台卡死拖垮整次搜索;超时平台以空结果 + errors 提示可观测。
|
|
463
|
+
"""
|
|
464
|
+
platform_results: dict[str, list] = {}
|
|
465
|
+
errors: list[str] = []
|
|
466
|
+
engines_used: list[str] = []
|
|
467
|
+
|
|
468
|
+
def _one(platform: str) -> tuple[str, list, str | None]:
|
|
469
|
+
module_name = platform.replace("-", "_") + "_engine"
|
|
470
|
+
try:
|
|
471
|
+
# 走 _lazy_cached,复用进程内模块缓存,避免重复 importlib 解析
|
|
472
|
+
mod = _lazy_cached(f"social_engines.{module_name}")
|
|
473
|
+
return platform, mod.search(query, n=n), None
|
|
474
|
+
except ImportError:
|
|
475
|
+
return platform, [], f"Platform {platform} not available (module social_engines.{module_name})"
|
|
476
|
+
except Exception as e:
|
|
477
|
+
return platform, [], f"{platform}: {str(e)[:100]}"
|
|
478
|
+
|
|
479
|
+
workers = min(max(len(platforms), 1), 4)
|
|
480
|
+
import concurrent.futures # 惰性导入:py3.14 的它模块级导入 dataclasses→inspect(~5.5ms),social-sentiment 分支外每次 MCP 调用都白付
|
|
481
|
+
ex = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
|
|
482
|
+
futs = {ex.submit(_one, p): p for p in platforms}
|
|
483
|
+
try:
|
|
484
|
+
for fut in concurrent.futures.as_completed(futs, timeout=timeout):
|
|
485
|
+
platform, results, err = fut.result()
|
|
486
|
+
platform_results[platform] = results
|
|
487
|
+
if err:
|
|
488
|
+
errors.append(err)
|
|
489
|
+
elif results is not None:
|
|
490
|
+
engines_used.append(platform)
|
|
491
|
+
except concurrent.futures.TimeoutError:
|
|
492
|
+
for fut, platform in futs.items():
|
|
493
|
+
if not fut.done():
|
|
494
|
+
fut.cancel()
|
|
495
|
+
platform_results[platform] = []
|
|
496
|
+
errors.append(f"{platform}: timeout (>{timeout}s)")
|
|
497
|
+
finally:
|
|
498
|
+
# 不等待未完成平台,让卡住的线程自生自灭(各引擎内部有 10-15s 保底超时)
|
|
499
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
500
|
+
return platform_results, engines_used, errors
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
504
|
+
"""读布尔环境变量;空/非法回退默认。"""
|
|
505
|
+
raw = os.environ.get(name, "")
|
|
506
|
+
if raw == "":
|
|
507
|
+
return default
|
|
508
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _env_int(name: str, default: int) -> int:
|
|
512
|
+
"""读整数环境变量;空/非法回退默认。"""
|
|
513
|
+
raw = os.environ.get(name, "")
|
|
514
|
+
if raw == "":
|
|
515
|
+
return default
|
|
516
|
+
try:
|
|
517
|
+
return int(raw)
|
|
518
|
+
except ValueError:
|
|
519
|
+
return default
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _clamp_int(value: Any, default: int, lo: int, hi: int) -> int:
|
|
523
|
+
"""边界夹取(与工具 schema 的 min/max 同计算方式):非法回默认,越界取边界。"""
|
|
524
|
+
try:
|
|
525
|
+
return max(lo, min(int(value), hi))
|
|
526
|
+
except (TypeError, ValueError):
|
|
527
|
+
return default
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class _InvalidParam(Exception):
|
|
531
|
+
"""工具参数类型/取值非法:外层转 -32602(区别于引擎内部故障的 -32000)。
|
|
532
|
+
|
|
533
|
+
刻意不继承 ValueError:工具分支里的 `except ValueError`(接 URL/解析
|
|
534
|
+
错误的)不能把参数错误半路截胡——实测 argo_article 就这样把它包成
|
|
535
|
+
了无错误码的裸 error。参数错误要活着到达 execute_tool 的专属 handler。
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _int_param(arguments: dict[str, Any], key: str, default: int) -> int:
|
|
540
|
+
"""数值参数严格校验:非整数显式 -32602,不静默回默认。
|
|
541
|
+
|
|
542
|
+
此前裸 `int(arguments.get("timeout", N))` 遇 "10s" 直接 ValueError,
|
|
543
|
+
被兜底 except 包成 -32000 内部错误——调用方看不出是参数给错了。
|
|
544
|
+
与 _required 缺参 -32602 同一契约:参数级错误走参数级错误码。
|
|
545
|
+
"""
|
|
546
|
+
value = arguments.get(key, default)
|
|
547
|
+
try:
|
|
548
|
+
return int(value)
|
|
549
|
+
except (TypeError, ValueError):
|
|
550
|
+
raise _InvalidParam(f"{key} 必须是整数,收到 {value!r}")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _cap_extract_output(output: dict[str, Any]) -> dict[str, Any]:
|
|
554
|
+
"""argo_fetch mode=extract 的输出上限(防大页表格撑爆上下文)。
|
|
555
|
+
|
|
556
|
+
结构级裁剪保持 JSON 可解析:表格数量/单元格、metadata 值、jsonld
|
|
557
|
+
条目体积分别设上限,超限截断并打标记。
|
|
558
|
+
"""
|
|
559
|
+
tables = output.get("tables")
|
|
560
|
+
if isinstance(tables, list):
|
|
561
|
+
if len(tables) > 12:
|
|
562
|
+
output["tables"] = tables[:12]
|
|
563
|
+
output["tables_truncated"] = True
|
|
564
|
+
for t in output["tables"]:
|
|
565
|
+
if isinstance(t, list):
|
|
566
|
+
for i, row in enumerate(t):
|
|
567
|
+
if isinstance(row, list):
|
|
568
|
+
t[i] = [str(c)[:300] if c is not None else "" for c in row]
|
|
569
|
+
meta = output.get("metadata")
|
|
570
|
+
if isinstance(meta, dict):
|
|
571
|
+
output["metadata"] = {k: str(v)[:500] for k, v in meta.items()}
|
|
572
|
+
jsonld = output.get("jsonld")
|
|
573
|
+
if isinstance(jsonld, list):
|
|
574
|
+
capped = []
|
|
575
|
+
for entry in jsonld:
|
|
576
|
+
try:
|
|
577
|
+
text = json.dumps(entry, ensure_ascii=False)
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
continue
|
|
580
|
+
if len(text) > 4000:
|
|
581
|
+
capped.append({"_clipped": True, "preview": text[:4000]})
|
|
582
|
+
else:
|
|
583
|
+
capped.append(entry)
|
|
584
|
+
if len(capped) > 8:
|
|
585
|
+
capped = capped[:8]
|
|
586
|
+
output["jsonld_truncated"] = True
|
|
587
|
+
output["jsonld"] = capped
|
|
588
|
+
return output
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
592
|
+
"""执行 MCP 工具,按需导入模块。
|
|
593
|
+
|
|
594
|
+
调试/运行配置(pretty / skip_cache / timeout)已从模型可见 schema 移出,
|
|
595
|
+
改为 server 层环境变量:ARGO_MCP_PRETTY / ARGO_MCP_SKIP_CACHE /
|
|
596
|
+
ARGO_MCP_TIMEOUT。arguments 里的旧字段仍兼容(低版本客户端可传)。
|
|
597
|
+
模型上下文不再为这些非决策参数付费,功能与调试能力不丢。
|
|
598
|
+
"""
|
|
599
|
+
pretty = _env_bool("ARGO_MCP_PRETTY", bool(arguments.get("pretty", False)))
|
|
600
|
+
try:
|
|
601
|
+
# 必填参数验证:缺参数时返回清晰错误,而非 KeyError → -32000
|
|
602
|
+
_required = {
|
|
603
|
+
"argo_search": ("query",),
|
|
604
|
+
"argo_research": ("query",),
|
|
605
|
+
"argo_social_search": ("query",),
|
|
606
|
+
"argo_evidence": ("query",),
|
|
607
|
+
"argo_clarify": ("query",),
|
|
608
|
+
"argo_crawl": ("url",),
|
|
609
|
+
"argo_fetch": ("url",),
|
|
610
|
+
"argo_screenshot": ("url",),
|
|
611
|
+
"argo_pdf": ("url",),
|
|
612
|
+
"argo_article": ("url",),
|
|
613
|
+
"argo_job": ("query",),
|
|
614
|
+
}
|
|
615
|
+
if name in _required:
|
|
616
|
+
missing = [p for p in _required[name] if not arguments.get(p)]
|
|
617
|
+
if missing:
|
|
618
|
+
return {
|
|
619
|
+
"content": [{"type": "text",
|
|
620
|
+
"text": _dumps({"error": {
|
|
621
|
+
"code": -32602,
|
|
622
|
+
"message": f"Missing required parameter(s): {', '.join(missing)}"
|
|
623
|
+
}})}],
|
|
624
|
+
"isError": True,
|
|
625
|
+
}
|
|
626
|
+
if name == "argo_search":
|
|
627
|
+
search_mod = _lazy_cached("search")
|
|
628
|
+
# MCP 默认 envelope=False:减 candidates/coverage 构造开销;
|
|
629
|
+
# 紧凑序列化仍由 _compact_search_result 负责
|
|
630
|
+
result = search_mod.super_search(
|
|
631
|
+
query=arguments["query"],
|
|
632
|
+
engine=arguments.get("engine", "auto"),
|
|
633
|
+
n=arguments.get("max_results", 5),
|
|
634
|
+
skip_cache=_env_bool("ARGO_MCP_SKIP_CACHE", _env_bool("ARGO_NO_CACHE", False) or bool(arguments.get("skip_cache", False))), # ARGO_NO_CACHE(CLI 等价 --no-cache)也覆盖 MCP:任一为真即跳过,显式传参优先
|
|
635
|
+
timeout=_env_int("ARGO_MCP_TIMEOUT", _int_param(arguments, "timeout", 10)),
|
|
636
|
+
depth=arguments.get("depth", "fast"),
|
|
637
|
+
mode=arguments.get("mode", "auto"),
|
|
638
|
+
since=arguments.get("since"),
|
|
639
|
+
until=arguments.get("until"),
|
|
640
|
+
sort=arguments.get("sort", "relevance"),
|
|
641
|
+
# 缺省 True(2026-09-29 扶正):本机文件命中默认并入对话搜索;
|
|
642
|
+
# 显式 false 可关。安全垫:宽根守卫 + 3s 子进程超时 + 并行提交。
|
|
643
|
+
include_local=bool(arguments.get("include_local", True)),
|
|
644
|
+
include_domains=arguments.get("include_domains") or None,
|
|
645
|
+
exclude_domains=arguments.get("exclude_domains") or None,
|
|
646
|
+
cache=_get_cache(),
|
|
647
|
+
envelope=False,
|
|
648
|
+
context="search",
|
|
649
|
+
)
|
|
650
|
+
# 默认精简;pretty 且 summary=false 时返回接近全量
|
|
651
|
+
summary = arguments.get("summary", True)
|
|
652
|
+
if summary or not pretty:
|
|
653
|
+
payload = _compact_search_result(result, summary=bool(summary))
|
|
654
|
+
else:
|
|
655
|
+
payload = result
|
|
656
|
+
return _ok(payload, pretty=pretty)
|
|
657
|
+
|
|
658
|
+
elif name == "argo_local_search":
|
|
659
|
+
# 本地文件搜索:封装 local-seek 的 seek.py(搜本机文件/记录,非联网)
|
|
660
|
+
query = str(arguments.get("query", "")).strip()
|
|
661
|
+
|
|
662
|
+
def _local_err(msg: str) -> dict:
|
|
663
|
+
return _ok({"query": query, "engine": "local_files", "count": 0, "results": [],
|
|
664
|
+
"errors": [msg]}, pretty=pretty)
|
|
665
|
+
|
|
666
|
+
if not query:
|
|
667
|
+
return _local_err("query 不能为空")
|
|
668
|
+
path = str(arguments.get("path", "~"))
|
|
669
|
+
max_results = _clamp_int(arguments.get("max_results", 5), 5, 1, 20)
|
|
670
|
+
exact = bool(arguments.get("exact", False))
|
|
671
|
+
# 宽泛根守卫(与 CLI --include-local 同判据):缺省 ~ 会对整个 home
|
|
672
|
+
# 跑 rg,实测 30 s 后报错(CLI 22.6 s 事故同形态)。判据唯一来源是
|
|
673
|
+
# local_seek._is_broad_local_root——它 2026-09-28 从 search.py 拆出后
|
|
674
|
+
# 这里还在调旧家,AttributeError 被 fail-open 吞掉,守卫整条失效
|
|
675
|
+
# (实测 home 路径一路跑到 15s 子进程超时)。
|
|
676
|
+
try:
|
|
677
|
+
if _lazy_cached("local_seek")._is_broad_local_root(os.path.expanduser(path)):
|
|
678
|
+
return _local_err("路径过宽(home / 根目录),扫全盘既慢又失准,已拒绝;请传具体目录")
|
|
679
|
+
except Exception as e:
|
|
680
|
+
# 守卫不可用 fail-open:按原行为继续,但必须留痕——静默跳过守卫
|
|
681
|
+
# 与「查询确实无命中」在外部看起来一样,排查时无从下手。
|
|
682
|
+
sys.stderr.write(
|
|
683
|
+
f"[argo-mcp] local_search 宽泛根守卫不可用({type(e).__name__}),fail-open\n")
|
|
684
|
+
# H2(2026-09-29):进程内直调 seek.run_query——常驻 MCP server 免
|
|
685
|
+
# 每次调用的解释器启动(实测省 30-110ms/次);time_budget 沿用
|
|
686
|
+
# 显式调用上界 15s(下传给内部 rg/fd 子进程)。任何失败回退下方
|
|
687
|
+
# 子进程路径:能力不回退,只是慢。
|
|
688
|
+
payload = None
|
|
689
|
+
try:
|
|
690
|
+
from local_seek import seek_query_payload
|
|
691
|
+
payload = seek_query_payload(query, os.path.expanduser(path),
|
|
692
|
+
max_results, exact=exact, time_budget=15)
|
|
693
|
+
except Exception:
|
|
694
|
+
payload = None
|
|
695
|
+
if payload is None:
|
|
696
|
+
seek_py = _seek_py()
|
|
697
|
+
cmd = [sys.executable, seek_py, query, "--path", path, "--json",
|
|
698
|
+
"--max", str(max_results)]
|
|
699
|
+
if exact:
|
|
700
|
+
cmd.append("--exact")
|
|
701
|
+
try:
|
|
702
|
+
# 子进程是我们的 seek_py:显式 UTF-8 双向(Windows 默认 GBK,中文
|
|
703
|
+
# query 的 JSON 输出会 mojibake/解码崩);超时 15 s 是显式调用上界
|
|
704
|
+
proc = subprocess.run(
|
|
705
|
+
cmd, capture_output=True, text=True,
|
|
706
|
+
encoding="utf-8", errors="replace", timeout=15,
|
|
707
|
+
env={**os.environ, "PYTHONUTF8": "1"})
|
|
708
|
+
except Exception as e:
|
|
709
|
+
return _local_err(f"本地搜索执行失败: {e}")
|
|
710
|
+
if proc.returncode != 0:
|
|
711
|
+
msg = (proc.stdout or proc.stderr or "").strip() or "本地搜索无结果"
|
|
712
|
+
return _local_err(msg)
|
|
713
|
+
try:
|
|
714
|
+
payload = json.loads(proc.stdout)
|
|
715
|
+
except Exception:
|
|
716
|
+
return _local_err("本地搜索输出解析失败")
|
|
717
|
+
mode = payload.get("mode", "fast")
|
|
718
|
+
score = 0.9 if mode == "fast" else 0.7 # 精确命中 0.9,扩展召回 0.7
|
|
719
|
+
results = []
|
|
720
|
+
for r in payload.get("results") or []:
|
|
721
|
+
abspath = os.path.abspath(r.get("path", ""))
|
|
722
|
+
line = r.get("line") or 0
|
|
723
|
+
results.append({
|
|
724
|
+
"title": abspath,
|
|
725
|
+
"url": f"file://{abspath}" + (f"#L{line}" if line else ""),
|
|
726
|
+
"snippet": _trim_snippet(r.get("snippet"), 120),
|
|
727
|
+
"source": "local_files", "score": score,
|
|
728
|
+
})
|
|
729
|
+
return _ok({
|
|
730
|
+
"query": query, "engine": "local_files", "engines_used": ["local_files"],
|
|
731
|
+
"count": len(results), "elapsed_ms": payload.get("elapsed_ms"),
|
|
732
|
+
"cached": False, "mode": mode, "depth": None,
|
|
733
|
+
"results": results, "errors": [],
|
|
734
|
+
}, pretty=pretty)
|
|
735
|
+
|
|
736
|
+
elif name == "argo_local_read":
|
|
737
|
+
# 白名单本地文本预览(非联网):分析本地数据/研究成果用;没配置就不放行
|
|
738
|
+
raw_path = str(arguments.get("path", "")).strip()
|
|
739
|
+
max_chars = max(200, min(_int_param(arguments, "max_chars", 4000), 20000))
|
|
740
|
+
line_start = arguments.get("line_start")
|
|
741
|
+
line_end = arguments.get("line_end")
|
|
742
|
+
if not raw_path:
|
|
743
|
+
return _ok({"path": "", "content": "", "errors": ["path 不能为空"]}, pretty=pretty)
|
|
744
|
+
try:
|
|
745
|
+
content, meta = _local_read_preview(
|
|
746
|
+
raw_path, max_chars=max_chars,
|
|
747
|
+
line_start=line_start, line_end=line_end,
|
|
748
|
+
)
|
|
749
|
+
except Exception as e:
|
|
750
|
+
return _ok({"path": raw_path, "content": "",
|
|
751
|
+
"errors": [str(e)]}, pretty=pretty)
|
|
752
|
+
return _ok({
|
|
753
|
+
"path": meta["path"],
|
|
754
|
+
"content": content,
|
|
755
|
+
"chars": meta["chars"],
|
|
756
|
+
"lines": meta["lines"],
|
|
757
|
+
"total_lines": meta["total_lines"],
|
|
758
|
+
"is_preview": meta["is_preview"],
|
|
759
|
+
"errors": [],
|
|
760
|
+
}, pretty=pretty)
|
|
761
|
+
|
|
762
|
+
elif name == "argo_recompute":
|
|
763
|
+
# 可复算执行器(默认拒绝):受限子进程运行计算脚本,验证数值。
|
|
764
|
+
# 默认拒绝(allow_exec=false / ARGO_ALLOW_RECOMPUTE 未设),返回 skipped_reason。
|
|
765
|
+
script = str(arguments.get("script", "")).strip()
|
|
766
|
+
inputs_raw = arguments.get("file_inputs")
|
|
767
|
+
inputs = []
|
|
768
|
+
if isinstance(inputs_raw, str) and inputs_raw.strip():
|
|
769
|
+
try:
|
|
770
|
+
inputs = json.loads(inputs_raw)
|
|
771
|
+
except ValueError:
|
|
772
|
+
inputs = []
|
|
773
|
+
elif isinstance(inputs_raw, list):
|
|
774
|
+
inputs = inputs_raw
|
|
775
|
+
elif inputs_raw:
|
|
776
|
+
try:
|
|
777
|
+
inputs = json.loads(inputs_raw)
|
|
778
|
+
except (ValueError, TypeError):
|
|
779
|
+
inputs = []
|
|
780
|
+
timeout_s = _clamp_int(arguments.get("timeout_s", 30), 30, 1, 120)
|
|
781
|
+
max_mem_mb = _clamp_int(arguments.get("max_mem_mb", 512), 512, 64, 4096)
|
|
782
|
+
allow_exec = bool(arguments.get("allow_exec", False))
|
|
783
|
+
if not script:
|
|
784
|
+
return _ok({"ok": False, "skipped_reason": "script 为空"}, pretty=pretty)
|
|
785
|
+
try:
|
|
786
|
+
from recompute import run_recompute
|
|
787
|
+
result = run_recompute(
|
|
788
|
+
script, inputs or None,
|
|
789
|
+
timeout_s=timeout_s, max_mem_mb=max_mem_mb,
|
|
790
|
+
allow_exec=allow_exec,
|
|
791
|
+
)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
result = {"ok": False, "skipped_reason": f"执行器异常: {e}"}
|
|
794
|
+
return _ok(result, pretty=pretty)
|
|
795
|
+
|
|
796
|
+
elif name == "argo_research":
|
|
797
|
+
research_mod = _lazy_cached("research")
|
|
798
|
+
mode = arguments.get("mode", "auto")
|
|
799
|
+
if mode == "social-sentiment":
|
|
800
|
+
platforms_str = arguments.get("platforms", "hackernews,zhihu,bilibili")
|
|
801
|
+
platforms = [p.strip() for p in platforms_str.split(",") if p.strip()]
|
|
802
|
+
result = research_mod.social_sentiment_research(
|
|
803
|
+
query=arguments["query"],
|
|
804
|
+
platforms=platforms,
|
|
805
|
+
max_results=arguments.get("max_results", 5),
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
profile, profile_key = _resolve_research_profile(arguments)
|
|
809
|
+
num_sub = arguments.get("num_sub_queries")
|
|
810
|
+
max_results = arguments.get("max_results")
|
|
811
|
+
depth = arguments.get("depth")
|
|
812
|
+
if profile:
|
|
813
|
+
if num_sub is None:
|
|
814
|
+
num_sub = profile.get("sub_queries", 4)
|
|
815
|
+
if max_results is None:
|
|
816
|
+
max_results = profile.get("max_results", 5)
|
|
817
|
+
if depth is None:
|
|
818
|
+
depth = profile.get("depth", "balanced")
|
|
819
|
+
result = research_mod.deep_research(
|
|
820
|
+
query=arguments["query"],
|
|
821
|
+
num_sub_queries=int(num_sub or 4),
|
|
822
|
+
max_results=int(max_results or 5),
|
|
823
|
+
depth=str(depth or "balanced"),
|
|
824
|
+
mode=mode,
|
|
825
|
+
profile=profile,
|
|
826
|
+
work_packages=arguments.get("work_packages"),
|
|
827
|
+
allow_recompute=bool(arguments.get("allow_recompute", False)),
|
|
828
|
+
)
|
|
829
|
+
if profile and profile_key:
|
|
830
|
+
result["topic_profile"] = profile.get("name")
|
|
831
|
+
result["topic_profile_key"] = profile_key
|
|
832
|
+
summary = arguments.get("summary", True)
|
|
833
|
+
if result.get("mode") == "social-sentiment":
|
|
834
|
+
payload = result
|
|
835
|
+
elif summary or not pretty:
|
|
836
|
+
payload = _compact_research_result(result, summary=bool(summary))
|
|
837
|
+
else:
|
|
838
|
+
payload = result
|
|
839
|
+
return _ok(payload, pretty=pretty)
|
|
840
|
+
|
|
841
|
+
elif name == "argo_social_search":
|
|
842
|
+
platforms_str = arguments.get("platforms", "hackernews,zhihu,bilibili")
|
|
843
|
+
platforms = [p.strip() for p in platforms_str.split(",") if p.strip()]
|
|
844
|
+
query = arguments["query"]
|
|
845
|
+
# 边界夹取与 schema(1..20)同计算方式:客户端越界值不放大平台请求
|
|
846
|
+
n = _clamp_int(arguments.get("max_results", 5), 5, 1, 20)
|
|
847
|
+
platform_results, engines_used, errors = _search_social_platforms(platforms, query, n)
|
|
848
|
+
if arguments.get("mode", "text") == "sentiment":
|
|
849
|
+
# 舆情聚合逻辑下沉在 research.aggregate_social_sentiment
|
|
850
|
+
research_mod = _lazy_cached("research")
|
|
851
|
+
output = research_mod.aggregate_social_sentiment(
|
|
852
|
+
query=query, platforms=platforms, platform_results=platform_results,
|
|
853
|
+
)
|
|
854
|
+
else:
|
|
855
|
+
all_results: list = []
|
|
856
|
+
for p in platforms:
|
|
857
|
+
all_results.extend(platform_results.get(p) or [])
|
|
858
|
+
output = {
|
|
859
|
+
"query": query,
|
|
860
|
+
"platforms": platforms,
|
|
861
|
+
"results": all_results,
|
|
862
|
+
"count": len(all_results),
|
|
863
|
+
"engines_used": engines_used,
|
|
864
|
+
}
|
|
865
|
+
if errors:
|
|
866
|
+
output["errors"] = errors
|
|
867
|
+
return _ok(output, pretty=pretty)
|
|
868
|
+
|
|
869
|
+
elif name == "argo_evidence":
|
|
870
|
+
results_json_str = arguments.get("results_json", "")
|
|
871
|
+
if not results_json_str or not results_json_str.strip():
|
|
872
|
+
search_mod = _lazy_cached("search")
|
|
873
|
+
search_result = search_mod.super_search(
|
|
874
|
+
query=arguments["query"],
|
|
875
|
+
n=arguments.get("max_results", 10),
|
|
876
|
+
depth="fast",
|
|
877
|
+
mode="auto",
|
|
878
|
+
cache=_get_cache(),
|
|
879
|
+
)
|
|
880
|
+
results = search_result.get("results", [])
|
|
881
|
+
else:
|
|
882
|
+
results_data = json.loads(results_json_str)
|
|
883
|
+
results = results_data.get("results", [])
|
|
884
|
+
evidence_mod = _lazy_cached("evidence")
|
|
885
|
+
result = evidence_mod.compute_credibility(results, arguments["query"])
|
|
886
|
+
return _ok(result, pretty=pretty)
|
|
887
|
+
|
|
888
|
+
elif name == "argo_clarify":
|
|
889
|
+
clarify_mod = _lazy_cached("clarify")
|
|
890
|
+
analysis = clarify_mod.analyze_query(arguments["query"])
|
|
891
|
+
routing = clarify_mod.recommend_routing(analysis)
|
|
892
|
+
analysis["routing"] = routing
|
|
893
|
+
return _ok(analysis, pretty=pretty)
|
|
894
|
+
|
|
895
|
+
elif name == "argo_crawl":
|
|
896
|
+
crawl_mod = _lazy_cached("crawl")
|
|
897
|
+
strategy = arguments.get("strategy", "bfs")
|
|
898
|
+
# 夹取与 schema 同计算方式(max_pages 1..50,max_depth 1..5)
|
|
899
|
+
max_pages = _clamp_int(arguments.get("max_pages", 10), 10, 1, 50)
|
|
900
|
+
max_depth = _clamp_int(arguments.get("max_depth", 2), 2, 1, 5)
|
|
901
|
+
timeout = _env_int("ARGO_MCP_TIMEOUT_CRAWL", _int_param(arguments, "timeout", 8))
|
|
902
|
+
if strategy == "sitemap":
|
|
903
|
+
result = crawl_mod.crawl_sitemap(arguments["url"], max_pages=max_pages, timeout=timeout)
|
|
904
|
+
else:
|
|
905
|
+
result = crawl_mod.crawl_bfs(arguments["url"], max_pages=max_pages, max_depth=max_depth, timeout=timeout)
|
|
906
|
+
return _ok(result, pretty=pretty)
|
|
907
|
+
|
|
908
|
+
elif name == "argo_fetch":
|
|
909
|
+
if arguments.get("mode", "text") == "extract":
|
|
910
|
+
# 结构化提取(原 argo_extract):raw fetch + tables/meta/jsonld
|
|
911
|
+
extract_mod = _lazy_cached("extract")
|
|
912
|
+
fetch_mod = _lazy_cached("fetch_v3")
|
|
913
|
+
emode = arguments.get("extract_mode", "all")
|
|
914
|
+
# 与 schema 同计算方式(默认 8000,500..50000);禁止硬编码 50000
|
|
915
|
+
# 把调用方 bound 顶掉——那是「上限字段被内部魔法数覆盖」一类 bug。
|
|
916
|
+
max_chars = _clamp_int(_int_param(arguments, "max_chars", 8000), 8000, 500, 50000)
|
|
917
|
+
fetch_result = fetch_mod.fetch_page_v3(
|
|
918
|
+
arguments["url"], max_chars=max_chars,
|
|
919
|
+
timeout=_env_int("ARGO_MCP_TIMEOUT_FETCH",
|
|
920
|
+
_int_param(arguments, "timeout", 15)), raw=True)
|
|
921
|
+
if not fetch_result["success"]:
|
|
922
|
+
return {
|
|
923
|
+
"content": [{"type": "text", "text": _dumps({"error": fetch_result.get("error", "fetch failed")})}],
|
|
924
|
+
"isError": True,
|
|
925
|
+
}
|
|
926
|
+
html = fetch_result["html"]
|
|
927
|
+
output = {}
|
|
928
|
+
if emode in ("tables", "all"):
|
|
929
|
+
output["tables"] = extract_mod.extract_tables(html)
|
|
930
|
+
if emode in ("metadata", "all"):
|
|
931
|
+
output["metadata"] = extract_mod.extract_metadata(html)
|
|
932
|
+
if emode in ("jsonld", "all"):
|
|
933
|
+
output["jsonld"] = extract_mod.extract_jsonld(html)
|
|
934
|
+
output["url"] = fetch_result["url"]
|
|
935
|
+
return _ok(_cap_extract_output(output), pretty=pretty)
|
|
936
|
+
|
|
937
|
+
focus_query = arguments.get("focus")
|
|
938
|
+
fetch_v3_mod = _lazy_cached("fetch_v3")
|
|
939
|
+
# 聚焦时按放大额度抓取,否则 focus 只能在文档头部选段;
|
|
940
|
+
# 额度契约与 CLI 同源(focus_extract.focus_fetch_chars)。
|
|
941
|
+
result = fetch_v3_mod.fetch_v3(
|
|
942
|
+
url=arguments["url"],
|
|
943
|
+
max_chars=fetch_v3_mod._focus_fetch_chars(
|
|
944
|
+
_int_param(arguments, "max_chars", 8000), focus_query),
|
|
945
|
+
timeout=_env_int("ARGO_MCP_TIMEOUT_FETCH", _int_param(arguments, "timeout", 15)),
|
|
946
|
+
use_browser_fallback=True,
|
|
947
|
+
force_browser=arguments.get("use_browser", False),
|
|
948
|
+
)
|
|
949
|
+
if focus_query and result.get("success"):
|
|
950
|
+
# 裁剪契约唯一来源在 focus_extract.apply_focus(CLI 的
|
|
951
|
+
# `argo fetch --focus` 走同一函数),此处不再自写一套:
|
|
952
|
+
# 两处各写一份的代价是语义静默分叉(CLI 侧曾整个漏掉该参数)。
|
|
953
|
+
focus_mod = _lazy_cached("focus_extract")
|
|
954
|
+
focus_mod.apply_focus(result, focus_query,
|
|
955
|
+
max_chars=_int_param(arguments, "max_chars", 8000))
|
|
956
|
+
if not result.get("focus_applied"):
|
|
957
|
+
# 正文过短、BM25 无从裁剪:如实标注,不谎报已省 token
|
|
958
|
+
result["focus_note"] = "内容未达聚焦阈值,返回全文"
|
|
959
|
+
# 默认截断 content 以控 token
|
|
960
|
+
if arguments.get("summary", True) and isinstance(result.get("content"), str):
|
|
961
|
+
max_c = _int_param(arguments, "max_chars", 8000)
|
|
962
|
+
if len(result["content"]) > max_c:
|
|
963
|
+
result["content"] = result["content"][:max_c]
|
|
964
|
+
result["truncated"] = True
|
|
965
|
+
# 双保险:fetch_v3 公共返回已剥 html;这里再剥一次,防其他入口漏
|
|
966
|
+
if isinstance(result, dict):
|
|
967
|
+
result.pop("html", None)
|
|
968
|
+
return _ok(result, pretty=pretty)
|
|
969
|
+
|
|
970
|
+
elif name == "argo_screenshot":
|
|
971
|
+
import time as _time
|
|
972
|
+
import tempfile # 惰性导入:仅 screenshot 分支需要,否则每次 MCP 调用白付 tempfile→shutil→random ~6ms
|
|
973
|
+
output = arguments.get("output_path") or os.path.join(
|
|
974
|
+
tempfile.gettempdir(), f"argo_screenshot_{int(_time.time())}.png")
|
|
975
|
+
full_page = arguments.get("full_page", False)
|
|
976
|
+
try:
|
|
977
|
+
cdp_mod = _lazy_cached("chrome_cdp")
|
|
978
|
+
# with 上下文:无论 navigate/screenshot 成功或抛异常,
|
|
979
|
+
# __exit__ 都会 stop() 回收 Chrome 进程与临时目录
|
|
980
|
+
with cdp_mod.ChromeCDP(auto_start=True) as cdp:
|
|
981
|
+
cdp.navigate(arguments["url"])
|
|
982
|
+
path = cdp.screenshot(output, full_page=full_page)
|
|
983
|
+
if path and os.path.exists(path):
|
|
984
|
+
return _ok({"success": True, "screenshot": path, "url": arguments["url"]}, pretty=pretty)
|
|
985
|
+
return {
|
|
986
|
+
"content": [{"type": "text", "text": _dumps({"success": False, "error": "screenshot failed"})}],
|
|
987
|
+
"isError": True,
|
|
988
|
+
}
|
|
989
|
+
except Exception as e:
|
|
990
|
+
return {
|
|
991
|
+
"content": [{"type": "text", "text": _dumps({"success": False, "error": str(e)[:200]})}],
|
|
992
|
+
"isError": True,
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
elif name == "argo_pdf":
|
|
996
|
+
pdf_mod = _lazy_cached("pdf_extract")
|
|
997
|
+
result = pdf_mod.extract_pdf(
|
|
998
|
+
url_or_path=arguments["url"],
|
|
999
|
+
pages=arguments.get("pages"),
|
|
1000
|
+
password=arguments.get("password"),
|
|
1001
|
+
)
|
|
1002
|
+
return _ok(result, pretty=pretty)
|
|
1003
|
+
|
|
1004
|
+
elif name == "argo_article":
|
|
1005
|
+
article_mod = _lazy_cached("article")
|
|
1006
|
+
try:
|
|
1007
|
+
out = article_mod.fetch_article(
|
|
1008
|
+
arguments["url"],
|
|
1009
|
+
timeout=_env_int("ARGO_MCP_TIMEOUT_FETCH",
|
|
1010
|
+
_int_param(arguments, "timeout", 30)),
|
|
1011
|
+
)
|
|
1012
|
+
except ValueError as e:
|
|
1013
|
+
# URL 不合规格式:可行动错误(仅支持 mp.weixin.qq.com)
|
|
1014
|
+
return {"content": [{"type": "text", "text": _dumps({"error": str(e)})}],
|
|
1015
|
+
"isError": True}
|
|
1016
|
+
if out.get("ok"):
|
|
1017
|
+
max_c = _int_param(arguments, "max_chars", 20000)
|
|
1018
|
+
if len(out.get("content", "")) > max_c:
|
|
1019
|
+
out["content"] = out["content"][:max_c]
|
|
1020
|
+
out["truncated"] = True
|
|
1021
|
+
return _ok(out, pretty=pretty)
|
|
1022
|
+
|
|
1023
|
+
elif name == "argo_job":
|
|
1024
|
+
job_mod = _lazy_cached("job")
|
|
1025
|
+
try:
|
|
1026
|
+
out = job_mod.search(
|
|
1027
|
+
arguments["query"],
|
|
1028
|
+
city=arguments.get("city", ""),
|
|
1029
|
+
# 夹取与 schema 同计算方式(num 1..20,fetch_detail 0..10):
|
|
1030
|
+
# 六平台并发 × 越界条数会同时放大请求量与返回体积
|
|
1031
|
+
num=_clamp_int(arguments.get("num", 5), 5, 1, 20),
|
|
1032
|
+
platforms=arguments.get("platforms", ""),
|
|
1033
|
+
loose=bool(arguments.get("loose", False)),
|
|
1034
|
+
fetch_n=_clamp_int(arguments.get("fetch_detail", 0), 0, 0, 10),
|
|
1035
|
+
)
|
|
1036
|
+
except ValueError as e:
|
|
1037
|
+
return {"content": [{"type": "text", "text": _dumps({"error": str(e)})}],
|
|
1038
|
+
"isError": True}
|
|
1039
|
+
return _ok(out, pretty=pretty)
|
|
1040
|
+
|
|
1041
|
+
# 2026-09-26 补齐 CLI 面的五工具(extract/preflight/answer/watch/cite)
|
|
1042
|
+
# 独立成模块:本文件涨破 1000 行硬上限,门禁要求拆分(route_* 先例)
|
|
1043
|
+
surface = _lazy_cached("mcp_handlers_surface")
|
|
1044
|
+
surface_result = surface.handle_surface_tool(name, arguments, pretty=pretty)
|
|
1045
|
+
if surface_result is not None:
|
|
1046
|
+
# 约定:surface 分支已返回完整 result(_ok 产物或 isError 形态),
|
|
1047
|
+
# 不再二次包装
|
|
1048
|
+
return surface_result
|
|
1049
|
+
|
|
1050
|
+
else:
|
|
1051
|
+
# 未知工具走 isError 形态(与其他工具级错误同契约):裸 {"error":...}
|
|
1052
|
+
# 会被 transport 包成成功 result,客户端无法区分错误与正常输出
|
|
1053
|
+
return {
|
|
1054
|
+
"content": [{"type": "text",
|
|
1055
|
+
"text": _dumps({"error": {"code": -32601,
|
|
1056
|
+
"message": f"Unknown tool: {name}"}})}],
|
|
1057
|
+
"isError": True,
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
except _InvalidParam as e:
|
|
1061
|
+
# 参数级错误走 -32602:与 _required 缺参同契约,调用方能分辨
|
|
1062
|
+
# 「参数给错了」和「引擎内部坏了」(后者才是 -32000)
|
|
1063
|
+
return {
|
|
1064
|
+
"content": [{"type": "text",
|
|
1065
|
+
"text": _dumps({"error": {"code": -32602,
|
|
1066
|
+
"message": f"Invalid parameter: {e}"}})}],
|
|
1067
|
+
"isError": True,
|
|
1068
|
+
}
|
|
1069
|
+
except Exception as e:
|
|
1070
|
+
return {
|
|
1071
|
+
"content": [{"type": "text", "text": _dumps({"error": {"code": -32000, "message": f"{type(e).__name__}: {e}"}})}],
|
|
1072
|
+
"isError": True
|
|
1073
|
+
}
|
|
1074
|
+
|