argo-search 1.0.1 → 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/LICENSE +201 -21
- package/README.en.md +735 -0
- package/README.es.md +777 -0
- package/README.ja.md +777 -0
- package/README.ko.md +777 -0
- package/README.md +778 -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/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2356 -96
- package/backends/engine_registry.yaml +3428 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2488 -160
- package/backends/source_types_cn.json +109 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +772 -0
- package/bin/argo.js +102 -39
- package/config.yaml +5139 -367
- 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 +575 -0
- package/docs/MCP_SETUP.md +108 -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/RELEASE_NOTES_v2.9.8.md +73 -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/cfets.yaml +41 -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/defillama.yaml +38 -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 +36 -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/gleif.yaml +55 -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/hkex.yaml +40 -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/keenable_free.yaml +36 -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/shfe.yaml +39 -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/tavily_free.yaml +56 -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/yuanbao.yaml +93 -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 +106 -0
- package/package.json +55 -12
- package/packages/dsh-plugin/README.md +152 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1533 -0
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +527 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
- 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 +38 -0
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +406 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +1382 -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 +294 -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/article_meta.py +131 -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/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1066 -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/ci_drift_gate.py +90 -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_boundary.py +46 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +751 -0
- package/scripts/crawl.py +74 -35
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +982 -0
- package/scripts/engine_env.py +539 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +738 -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 +799 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +310 -0
- package/scripts/engines_builders_batch10.py +204 -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_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -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_keyless.py +111 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1359 -0
- package/scripts/evidence.py +620 -166
- package/scripts/evidence_loop.py +624 -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 +1804 -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 +195 -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.swift +126 -0
- package/scripts/install.ps1 +166 -0
- package/scripts/install.sh +221 -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_image.py +970 -0
- package/scripts/local_seek.py +402 -0
- package/scripts/macro_countries.py +97 -0
- package/scripts/matrix_search_eval.py +1187 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +492 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +124 -0
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +125 -419
- package/scripts/mcp_setup.py +431 -0
- package/scripts/mcp_tools.py +331 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/mcp_warm.py +127 -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/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -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 +633 -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/recovery_runner.py +150 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +658 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +607 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +169 -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/retry_policy.py +76 -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 +243 -0
- package/scripts/route_combo.py +917 -0
- package/scripts/route_domains.py +341 -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 +886 -406
- package/scripts/search_attribution.py +185 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +640 -0
- package/scripts/search_entry.py +395 -0
- package/scripts/search_output.py +690 -0
- package/scripts/search_pipeline.py +926 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +129 -16
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +391 -0
- package/scripts/serp_guard.py +336 -0
- package/scripts/serp_kill_log.py +74 -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 +107 -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 +447 -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/scripts/zhihu_quota.py +139 -0
- package/sub-skills/ego-search/SKILL.md +253 -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/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/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +186 -0
- package/sub-skills/local-search/cli_runner.py +331 -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/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +716 -242
- 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/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 +197 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""browser_auth.py — argo 自持浏览器登录态(持久 profile,A2 车道)
|
|
3
|
+
|
|
4
|
+
落地方案:docs-local/连接器能力接入_方案调研_2026-09-26.md §4 方案 A 的 A2。
|
|
5
|
+
|
|
6
|
+
模型(与既有凭据纪律对齐):
|
|
7
|
+
- profile 是 argo 自持的独立 Chromium profile(不是用户主 Chrome),
|
|
8
|
+
落在状态根 browser-profiles/<site>/。
|
|
9
|
+
- cookie 只存在于 Chrome 自己的存储里(macOS Keychain / Windows DPAPI
|
|
10
|
+
加密),argo 全程不读取、不导出 cookie 值——比导出 state 文件再加密
|
|
11
|
+
(agent-browser --session 的做法)少一类落盘凭据。
|
|
12
|
+
- 抓取走 chrome_cdp 的「页面内带凭证 fetch」(fetch_json):cookie 由
|
|
13
|
+
浏览器附加,argo 只收响应体。
|
|
14
|
+
- 登录态结果标 login_state_used=True / cache_eligible=False,
|
|
15
|
+
cache.assert_cacheable 据此拒绝进公共缓存。
|
|
16
|
+
|
|
17
|
+
CLI(bin/argo 的 auth 子命令):
|
|
18
|
+
argo auth login <url> 开可见窗口登录/扫码一次(Ctrl-C 放弃)
|
|
19
|
+
argo auth status 列出已登录站点
|
|
20
|
+
argo auth logout <site> 删除该站点 profile
|
|
21
|
+
|
|
22
|
+
开关:ARGO_FETCH_AUTH=0 关闭抓取侧登录态车道;auth login/status/logout
|
|
23
|
+
不受它管(用户显式管理自己的 profile 永远可用)。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
ENV_AUTH_FETCH = "ARGO_AUTH_FETCH"
|
|
37
|
+
|
|
38
|
+
# 备名:与抓取降级链分级开关家族(ARGO_FETCH_*)保持同族命名
|
|
39
|
+
ENV_AUTH_FETCH_ALIAS = "ARGO_FETCH_AUTH"
|
|
40
|
+
|
|
41
|
+
_HOST_RE = re.compile(r"[^a-z0-9.-]")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def auth_fetch_enabled() -> bool:
|
|
45
|
+
"""抓取侧登录态车道开关:ARGO_AUTH_FETCH / ARGO_FETCH_AUTH=0 关闭,默认开启。"""
|
|
46
|
+
for name in (ENV_AUTH_FETCH, ENV_AUTH_FETCH_ALIAS):
|
|
47
|
+
try:
|
|
48
|
+
from engine_env import get_env
|
|
49
|
+
val = get_env(name)
|
|
50
|
+
except ImportError:
|
|
51
|
+
val = os.environ.get(name, "")
|
|
52
|
+
if str(val).strip().lower() in ("0", "false", "no", "off"):
|
|
53
|
+
return False
|
|
54
|
+
if str(val).strip():
|
|
55
|
+
return True
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def profile_root() -> Path:
|
|
60
|
+
"""持久 profile 根目录:状态根/browser-profiles(ARGO_STATE_DIR 可整体隔离)。"""
|
|
61
|
+
from argo_paths import state_root
|
|
62
|
+
return state_root() / "browser-profiles"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def site_host(url_or_host: str) -> str:
|
|
66
|
+
"""URL 或主机名 → 站点身份(去 scheme/port/path,去 www.,小写)。
|
|
67
|
+
|
|
68
|
+
返回值直接用作 profile 目录名,所以顺带做文件名安全审查:
|
|
69
|
+
出现 [^a-z0-9.-](路径穿越、空格、怪字符)或没有点(排除裸词)
|
|
70
|
+
一律拒绝。localhost 显式放行(本地开发站登录是真实场景)。
|
|
71
|
+
"""
|
|
72
|
+
s = (url_or_host or "").strip()
|
|
73
|
+
if "://" in s:
|
|
74
|
+
s = s.split("://", 1)[1]
|
|
75
|
+
s = s.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
|
|
76
|
+
if s.endswith(":80") or s.endswith(":443"):
|
|
77
|
+
s = s.rsplit(":", 1)[0]
|
|
78
|
+
if ":" in s: # 带端口(或 IPv6 字面量)→ 取端口前段;IPv6 不支持,报错更诚实
|
|
79
|
+
head = s.split(":", 1)[0]
|
|
80
|
+
if not head:
|
|
81
|
+
raise ValueError(f"unsupported site: {url_or_host!r}")
|
|
82
|
+
s = head
|
|
83
|
+
s = s.lower()
|
|
84
|
+
if s.startswith("www."):
|
|
85
|
+
s = s[4:]
|
|
86
|
+
if not s or _HOST_RE.search(s):
|
|
87
|
+
raise ValueError(f"invalid site: {url_or_host!r}")
|
|
88
|
+
if s.startswith("."): # "." / ".." / ".com":点号打头做目录名就是穿越
|
|
89
|
+
raise ValueError(f"invalid site: {url_or_host!r}")
|
|
90
|
+
if "." not in s and s != "localhost":
|
|
91
|
+
raise ValueError(f"invalid site (no dot): {url_or_host!r}")
|
|
92
|
+
return s
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _profile_dir(host: str) -> Path:
|
|
96
|
+
return profile_root() / host
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_meta(d: Path) -> dict:
|
|
100
|
+
try:
|
|
101
|
+
return json.loads((d / "meta.json").read_text(encoding="utf-8"))
|
|
102
|
+
except Exception:
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_meta(d: Path, host: str, created: str | None = None) -> None:
|
|
107
|
+
"""原子写 meta.json:profile 的 argo 登记标记(Chrome 不认识也不在乎它)。"""
|
|
108
|
+
prev = _read_meta(d)
|
|
109
|
+
meta = {
|
|
110
|
+
"site": host,
|
|
111
|
+
"created": created or prev.get("created") or time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
112
|
+
"last_used": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
113
|
+
"argo": "browser-profiles/v1",
|
|
114
|
+
}
|
|
115
|
+
tmp = d / f"meta.json.tmp.{os.getpid()}"
|
|
116
|
+
tmp.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
|
|
117
|
+
os.replace(tmp, d / "meta.json")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def profile_for(url_or_host: str) -> str | None:
|
|
121
|
+
"""该站点的持久 profile 路径;没登录过(或站点名不合法)返回 None。
|
|
122
|
+
|
|
123
|
+
以 meta.json 存在为准:裸目录可能是上次登录失败的残骸,不能当登录态。
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
host = site_host(url_or_host)
|
|
127
|
+
except ValueError:
|
|
128
|
+
return None
|
|
129
|
+
d = _profile_dir(host)
|
|
130
|
+
if d.is_dir() and (d / "meta.json").is_file():
|
|
131
|
+
return str(d)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def auth_profile_for_fetch(url_or_host: str,
|
|
136
|
+
allow_browser_lane: bool = True) -> str | None:
|
|
137
|
+
"""抓取侧入口:车道让路(开关关 / 调用方禁浏览器 / 未登录)→ None。
|
|
138
|
+
|
|
139
|
+
allow_browser_lane=False 对应 use_browser_fallback=False 的调用方
|
|
140
|
+
(批量爬取、evidence 核验等刻意无浏览器的路径)——登录态车道同样让路,
|
|
141
|
+
未认证不破默认路径的另一半。
|
|
142
|
+
"""
|
|
143
|
+
if not allow_browser_lane or not auth_fetch_enabled():
|
|
144
|
+
return None
|
|
145
|
+
return profile_for(url_or_host)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def login(url_or_host: str, chrome_path: str | None = None,
|
|
149
|
+
confirm=None) -> dict:
|
|
150
|
+
"""开可见 Chrome 窗口让用户登录一次,profile 持久保存。
|
|
151
|
+
|
|
152
|
+
confirm:等待登录完成的钩子(None → 终端 input())。可注入以便测试。
|
|
153
|
+
"""
|
|
154
|
+
host = site_host(url_or_host)
|
|
155
|
+
d = _profile_dir(host)
|
|
156
|
+
fresh = not d.is_dir()
|
|
157
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
target = url_or_host.strip()
|
|
160
|
+
if "://" not in target:
|
|
161
|
+
target = "https://" + target
|
|
162
|
+
|
|
163
|
+
from chrome_cdp import _ChromeProcess
|
|
164
|
+
proc = _ChromeProcess(chrome_path=chrome_path, headless=False,
|
|
165
|
+
user_data_dir=str(d), start_url=target)
|
|
166
|
+
try:
|
|
167
|
+
proc.start()
|
|
168
|
+
except Exception as e:
|
|
169
|
+
# 新建 profile 时启动即败(占位 SingletonLock / 磁盘满等)→ 别留裸目录
|
|
170
|
+
if fresh:
|
|
171
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
172
|
+
msg = str(e)
|
|
173
|
+
if "CDP failed to start" in msg or "Singleton" in msg:
|
|
174
|
+
msg += "(该 profile 可能已有 Chrome 在跑:关掉它或先 argo auth logout)"
|
|
175
|
+
return {"ok": False, "site": host, "error": msg[:200]}
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
if confirm is None:
|
|
179
|
+
print(f"\n[{host}] 已在打开的 Chrome 窗口中登录/扫码。", file=sys.stderr)
|
|
180
|
+
print("完成后回到终端按 Enter 保存登录态(Ctrl-C 放弃):", file=sys.stderr)
|
|
181
|
+
try:
|
|
182
|
+
input()
|
|
183
|
+
except EOFError:
|
|
184
|
+
return {"ok": False, "site": host,
|
|
185
|
+
"error": "非交互终端无法确认登录完成(需要 tty)"}
|
|
186
|
+
else:
|
|
187
|
+
confirm()
|
|
188
|
+
_write_meta(d, host)
|
|
189
|
+
return {"ok": True, "site": host, "profile": str(d)}
|
|
190
|
+
finally:
|
|
191
|
+
try:
|
|
192
|
+
proc.stop()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def status() -> list[dict]:
|
|
198
|
+
"""已登录站点清单(只读 meta,绝不触碰 profile 内的浏览器存储)。"""
|
|
199
|
+
root = profile_root()
|
|
200
|
+
if not root.is_dir():
|
|
201
|
+
return []
|
|
202
|
+
out = []
|
|
203
|
+
for d in sorted(root.iterdir()):
|
|
204
|
+
if not (d.is_dir() and (d / "meta.json").is_file()):
|
|
205
|
+
continue
|
|
206
|
+
m = _read_meta(d)
|
|
207
|
+
out.append({
|
|
208
|
+
"site": m.get("site") or d.name,
|
|
209
|
+
"created": m.get("created"),
|
|
210
|
+
"last_used": m.get("last_used"),
|
|
211
|
+
"path": str(d),
|
|
212
|
+
})
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def logout(url_or_host: str) -> dict:
|
|
217
|
+
"""删除站点 profile。Chrome 还开着该 profile 时拒绝(SingletonLock 探活)。"""
|
|
218
|
+
host = site_host(url_or_host)
|
|
219
|
+
d = _profile_dir(host)
|
|
220
|
+
if not d.is_dir():
|
|
221
|
+
return {"ok": False, "site": host, "error": f"no profile for {host}"}
|
|
222
|
+
lock = d / "SingletonLock"
|
|
223
|
+
if lock.is_symlink():
|
|
224
|
+
# Chrome 的 SingletonLock 是 hostname-PID 软链;PID 活着 → 别删活人脚下的地毯
|
|
225
|
+
pid = os.readlink(lock).rsplit("-", 1)[-1]
|
|
226
|
+
if pid.isdigit():
|
|
227
|
+
try:
|
|
228
|
+
os.kill(int(pid), 0)
|
|
229
|
+
except OSError:
|
|
230
|
+
pass # 进程已死:锁是残骸,放行删除
|
|
231
|
+
else:
|
|
232
|
+
return {"ok": False, "site": host,
|
|
233
|
+
"error": f"Chrome(pid {pid}) 正在使用该 profile,先关闭再 logout"}
|
|
234
|
+
shutil.rmtree(d)
|
|
235
|
+
return {"ok": True, "site": host}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def touch(url_or_host: str) -> None:
|
|
239
|
+
"""抓取命中登录态车道时更新 last_used(失败静默——只是观测字段)。"""
|
|
240
|
+
try:
|
|
241
|
+
host = site_host(url_or_host)
|
|
242
|
+
d = _profile_dir(host)
|
|
243
|
+
if d.is_dir() and (d / "meta.json").is_file():
|
|
244
|
+
_write_meta(d, host)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ─── CLI(bin/argo 的 auth 子命令入口)──────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def main(argv: list[str] | None = None) -> None:
|
|
252
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
253
|
+
use_json = "--json" in argv
|
|
254
|
+
argv = [a for a in argv if a != "--json"]
|
|
255
|
+
|
|
256
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
257
|
+
print(__doc__.strip() or "argo auth — 浏览器登录态")
|
|
258
|
+
sys.exit(0 if argv else 1)
|
|
259
|
+
|
|
260
|
+
cmd, rest = argv[0], argv[1:]
|
|
261
|
+
|
|
262
|
+
def _emit_json(payload: dict) -> None:
|
|
263
|
+
# stdout 一律紧凑 JSON(context-budget 门禁:禁缩进 JSON / dumps_pretty)
|
|
264
|
+
try:
|
|
265
|
+
from cli_io import dumps
|
|
266
|
+
print(dumps(payload))
|
|
267
|
+
except ImportError:
|
|
268
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
269
|
+
|
|
270
|
+
if cmd == "login":
|
|
271
|
+
if not rest:
|
|
272
|
+
print("用法:argo auth login <url>(如 argo auth login https://example.com)",
|
|
273
|
+
file=sys.stderr)
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
chrome_path = None
|
|
276
|
+
if "--chrome-path" in rest:
|
|
277
|
+
i = rest.index("--chrome-path")
|
|
278
|
+
if i + 1 < len(rest):
|
|
279
|
+
chrome_path = rest[i + 1]
|
|
280
|
+
del rest[i:i + 2]
|
|
281
|
+
out = login(rest[0], chrome_path=chrome_path)
|
|
282
|
+
if use_json:
|
|
283
|
+
_emit_json(out)
|
|
284
|
+
elif out.get("ok"):
|
|
285
|
+
print(f"已保存 {out['site']} 的登录态({out['profile']})\n"
|
|
286
|
+
f"之后 argo fetch 该站点自动带会话;argo auth status 可查看。")
|
|
287
|
+
else:
|
|
288
|
+
print(f"登录失败:{out.get('error')}", file=sys.stderr)
|
|
289
|
+
sys.exit(0 if out.get("ok") else 1)
|
|
290
|
+
|
|
291
|
+
if cmd == "status":
|
|
292
|
+
profiles = status()
|
|
293
|
+
if use_json:
|
|
294
|
+
_emit_json({"profiles": profiles})
|
|
295
|
+
elif not profiles:
|
|
296
|
+
print("(尚无已登录站点——argo auth login <url> 添加)")
|
|
297
|
+
else:
|
|
298
|
+
for p in profiles:
|
|
299
|
+
print(f"{p['site']} 建于 {p.get('created') or '?'} "
|
|
300
|
+
f"最近使用 {p.get('last_used') or '?'}")
|
|
301
|
+
sys.exit(0)
|
|
302
|
+
|
|
303
|
+
if cmd == "logout":
|
|
304
|
+
if not rest:
|
|
305
|
+
print("用法:argo auth logout <site>(站点见 argo auth status)",
|
|
306
|
+
file=sys.stderr)
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
out = logout(rest[0])
|
|
309
|
+
if use_json:
|
|
310
|
+
_emit_json(out)
|
|
311
|
+
elif out.get("ok"):
|
|
312
|
+
print(f"已删除 {out['site']} 的登录态 profile。")
|
|
313
|
+
else:
|
|
314
|
+
print(f"删除失败:{out.get('error')}", file=sys.stderr)
|
|
315
|
+
sys.exit(0 if out.get("ok") else 1)
|
|
316
|
+
|
|
317
|
+
print(f"Unknown auth subcommand: {cmd}(可用:login / status / logout)",
|
|
318
|
+
file=sys.stderr)
|
|
319
|
+
sys.exit(1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
main()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""budget.py — 按引擎的月度预算(原生币种)。
|
|
3
|
+
|
|
4
|
+
为什么需要(2026-10-21):`quota.py` 的额度是**次数**(limit/used),而付费引擎
|
|
5
|
+
(DeepSeek、Tavily 等)的真实约束是**钱**。按次数换算会在单价变化时失真,也会
|
|
6
|
+
把「失败调用」算成花费(DeepSeek 只对成功请求计费)。本模块只做三件事:
|
|
7
|
+
|
|
8
|
+
1. 读取预算(默认 → 共享文件 → env 覆盖),不碰配额状态;
|
|
9
|
+
2. 把预算挂到 `quota.py` 的 profile 上(budget_amount / budget_currency / period);
|
|
10
|
+
3. 提供 `argo budget set|list|unset` 的存储原语。
|
|
11
|
+
|
|
12
|
+
存储位置:`~/.config/argo/budgets.json`(POSIX)或 `%APPDATA%\\argo\\budgets.json`
|
|
13
|
+
(Windows),与 `~/.config/argo/env` 同根;`ARGO_BUDGET_FILE` 可覆盖(测试/多环境)。
|
|
14
|
+
格式:
|
|
15
|
+
|
|
16
|
+
{"deepseek_search": {"amount": 5.0, "currency": "CNY", "period": "month"}}
|
|
17
|
+
|
|
18
|
+
env 覆盖(优先级高于文件,便于插件/临时实验):
|
|
19
|
+
- `ARGO_BUDGETS_JSON`:整份 JSON(最高)
|
|
20
|
+
- `ARGO_BUDGET_<ENGINE>`:单引擎金额(币种按引擎 cost_unit,缺省 CNY;周期 month)
|
|
21
|
+
|
|
22
|
+
默认只给 DeepSeek 一个月度体验额度(¥5 ≈ 1000 次成功调用);其他引擎默认不设,
|
|
23
|
+
沿用各自 quota_profiles.json 里的既有额度,符合「其他默认不改」。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_BUDGETS",
|
|
35
|
+
"budget_file",
|
|
36
|
+
"budget_signature",
|
|
37
|
+
"load_budgets",
|
|
38
|
+
"budget_for",
|
|
39
|
+
"apply_to_profiles",
|
|
40
|
+
"set_budget",
|
|
41
|
+
"unset_budget",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# 默认预算:只覆盖 DeepSeek。金额按人民币;折算依据是官方 5 元/千次成功请求。
|
|
45
|
+
DEFAULT_BUDGETS: dict[str, dict[str, Any]] = {
|
|
46
|
+
"deepseek_search": {"amount": 5.0, "currency": "CNY", "period": "month"},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_ENV_JSON = "ARGO_BUDGETS_JSON"
|
|
50
|
+
_ENV_FILE = "ARGO_BUDGET_FILE"
|
|
51
|
+
_ENV_PREFIX = "ARGO_" + "BUDGET_"
|
|
52
|
+
|
|
53
|
+
# 单引擎 env 覆盖的默认币种(金额单位必须与引擎 cost_unit 对齐,否则预算
|
|
54
|
+
# 与 cost_used 是两个币种在比大小)。未列出的引擎按 CNY,与历史行为一致。
|
|
55
|
+
_ENV_CURRENCY = {
|
|
56
|
+
"deepseek_search": "CNY",
|
|
57
|
+
"exa": "USD",
|
|
58
|
+
"tavily": "USD",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def budget_file() -> Path:
|
|
63
|
+
"""预算文件路径(不保证存在)。"""
|
|
64
|
+
override = (os.environ.get(_ENV_FILE) or "").strip()
|
|
65
|
+
if override:
|
|
66
|
+
return Path(os.path.expanduser(override))
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
base = (os.environ.get("APPDATA") or "").strip() or os.path.expanduser("~")
|
|
69
|
+
else:
|
|
70
|
+
base = (os.environ.get("XDG_CONFIG_HOME") or "").strip() or os.path.expanduser("~/.config")
|
|
71
|
+
return Path(base) / "argo" / "budgets.json"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _valid(entry: Any) -> dict[str, Any] | None:
|
|
75
|
+
"""校验并归一一条预算;坏条目返回 None(不阻断其他引擎)。"""
|
|
76
|
+
if not isinstance(entry, dict):
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
amount = float(entry.get("amount"))
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return None
|
|
82
|
+
if amount < 0:
|
|
83
|
+
return None
|
|
84
|
+
currency = str(entry.get("currency") or "CNY").strip().upper() or "CNY"
|
|
85
|
+
period = str(entry.get("period") or "month").strip().lower()
|
|
86
|
+
if period not in ("day", "month"):
|
|
87
|
+
period = "month"
|
|
88
|
+
return {"amount": amount, "currency": currency, "period": period}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_file() -> dict[str, dict[str, Any]]:
|
|
92
|
+
try:
|
|
93
|
+
raw = json.loads(budget_file().read_text(encoding="utf-8"))
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
return {}
|
|
96
|
+
if not isinstance(raw, dict):
|
|
97
|
+
return {}
|
|
98
|
+
out: dict[str, dict[str, Any]] = {}
|
|
99
|
+
for engine, entry in raw.items():
|
|
100
|
+
if str(engine).startswith("_"):
|
|
101
|
+
continue
|
|
102
|
+
norm = _valid(entry)
|
|
103
|
+
if norm is not None:
|
|
104
|
+
out[str(engine)] = norm
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_env() -> dict[str, dict[str, Any]]:
|
|
109
|
+
out: dict[str, dict[str, Any]] = {}
|
|
110
|
+
raw = (os.environ.get(_ENV_JSON) or "").strip()
|
|
111
|
+
if raw:
|
|
112
|
+
try:
|
|
113
|
+
parsed = json.loads(raw)
|
|
114
|
+
except ValueError:
|
|
115
|
+
parsed = None
|
|
116
|
+
if isinstance(parsed, dict):
|
|
117
|
+
for engine, entry in parsed.items():
|
|
118
|
+
norm = _valid(entry)
|
|
119
|
+
if norm is not None:
|
|
120
|
+
out[str(engine)] = norm
|
|
121
|
+
# 单引擎便捷覆盖:"ARGO_BUDGET_DEEPSEEK_SEARCH"=5 → deepseek_search 5 CNY/月。
|
|
122
|
+
# 同族命名 ARGO_BUDGET_<ENGINE> 由下面按前缀解析;字面量保留给插件门禁对拍。
|
|
123
|
+
# 只认「大写化后能在默认/文件里找到的引擎」太窄;这里按环境变量名反推引擎名,
|
|
124
|
+
# 由调用方(quota)决定是否真的存在该引擎,避免拼错静默生效。
|
|
125
|
+
for key, value in os.environ.items():
|
|
126
|
+
if not key.startswith(_ENV_PREFIX) or key in (_ENV_JSON, _ENV_FILE):
|
|
127
|
+
continue
|
|
128
|
+
engine = key[len(_ENV_PREFIX):].strip().lower()
|
|
129
|
+
if not engine:
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
amount = float(str(value).strip())
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
continue
|
|
135
|
+
if amount < 0:
|
|
136
|
+
continue
|
|
137
|
+
currency = (out.get(engine) or {}).get("currency") \
|
|
138
|
+
or _ENV_CURRENCY.get(engine, "CNY")
|
|
139
|
+
entry = {"amount": amount, "currency": currency, "period": "month"}
|
|
140
|
+
out[engine] = entry
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def budget_signature() -> tuple[Any, ...]:
|
|
145
|
+
"""预算配置源的轻量签名(文件 mtime/size + 环境变量覆盖),供热读与路由缓存换键。"""
|
|
146
|
+
p = budget_file()
|
|
147
|
+
try:
|
|
148
|
+
st = p.stat()
|
|
149
|
+
f_sig: tuple[Any, ...] = (st.st_mtime_ns, st.st_size)
|
|
150
|
+
except OSError:
|
|
151
|
+
f_sig = (0, 0)
|
|
152
|
+
return (
|
|
153
|
+
f_sig,
|
|
154
|
+
os.environ.get(_ENV_JSON, ""),
|
|
155
|
+
os.environ.get("ARGO_BUDGET_DEEPSEEK_SEARCH", ""),
|
|
156
|
+
os.environ.get("ARGO_BUDGET_EXA", ""),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_budgets() -> dict[str, dict[str, Any]]:
|
|
161
|
+
"""默认 → 文件 → env,逐层覆盖。"""
|
|
162
|
+
merged: dict[str, dict[str, Any]] = {k: dict(v) for k, v in DEFAULT_BUDGETS.items()}
|
|
163
|
+
merged.update(_read_file())
|
|
164
|
+
merged.update(_read_env())
|
|
165
|
+
return merged
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def budget_for(engine: str) -> dict[str, Any] | None:
|
|
169
|
+
return load_budgets().get(str(engine))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def apply_to_profiles(profiles: dict[str, dict[str, Any]]) -> None:
|
|
173
|
+
"""把预算挂到 quota profile(就地)。只覆盖存在的引擎,拼错不生效。"""
|
|
174
|
+
active = load_budgets()
|
|
175
|
+
for engine, spec in profiles.items():
|
|
176
|
+
if not isinstance(spec, dict) or str(engine).startswith("_"):
|
|
177
|
+
continue
|
|
178
|
+
entry = active.get(engine)
|
|
179
|
+
if entry is not None:
|
|
180
|
+
spec["budget_amount"] = entry["amount"]
|
|
181
|
+
spec["budget_currency"] = entry["currency"]
|
|
182
|
+
# 预算周期即重置周期;与 limit 共用同一条 `period`,避免两套时钟。
|
|
183
|
+
spec["period"] = entry["period"]
|
|
184
|
+
else:
|
|
185
|
+
spec.pop("budget_amount", None)
|
|
186
|
+
spec.pop("budget_currency", None)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _write(data: dict[str, Any]) -> None:
|
|
190
|
+
path = budget_file()
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
193
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
194
|
+
encoding="utf-8")
|
|
195
|
+
os.replace(tmp, path)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def over_budget_error(engine: str) -> str | None:
|
|
199
|
+
"""预算耗尽时返回可行动错误文本;未耗尽/未配预算/读失败返回 None。
|
|
200
|
+
|
|
201
|
+
放在 budget.py 而不是 dispatch:engine_dispatch 有行数门禁,预算检查是
|
|
202
|
+
「读状态 + 拼一句话」的纯策略,搬出来也让显式/自动路径共用同一文案。
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
from engine_env import env_flag
|
|
206
|
+
if env_flag("ARGO_PAID_BUDGET_BYPASS", default=False):
|
|
207
|
+
return None
|
|
208
|
+
except Exception:
|
|
209
|
+
if (os.environ.get("ARGO_PAID_BUDGET_BYPASS", "").strip().lower()
|
|
210
|
+
in ("1", "true", "yes", "on")):
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
from quota import get_quota_manager
|
|
214
|
+
qm = get_quota_manager()
|
|
215
|
+
# 只认真正的 True:测试常把 quota manager 打成 MagicMock,属性调用
|
|
216
|
+
# 会返回真值 mock——不能把「mock 存在」当成预算耗尽。
|
|
217
|
+
if qm.is_over_budget(engine) is not True:
|
|
218
|
+
return None
|
|
219
|
+
st = (qm.budget_status(engine) if hasattr(qm, "budget_status")
|
|
220
|
+
else (qm.get_stats().get(engine) or {}))
|
|
221
|
+
return (f"本月预算已用完({st.get('cost_used', 0)}/{st.get('budget')} "
|
|
222
|
+
f"{st.get('budget_currency') or ''});"
|
|
223
|
+
f"可用 ARGO_PAID_BUDGET_BYPASS=1 单次放行")
|
|
224
|
+
except Exception:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def set_budget(engine: str, amount: float, *, currency: str = "CNY",
|
|
229
|
+
period: str = "month") -> dict[str, Any]:
|
|
230
|
+
"""写入一条预算(保留文件里其他引擎)。返回归一后的条目。"""
|
|
231
|
+
norm = _valid({"amount": amount, "currency": currency, "period": period})
|
|
232
|
+
if norm is None:
|
|
233
|
+
raise ValueError("预算金额必须是非负数")
|
|
234
|
+
data = _read_file()
|
|
235
|
+
data[str(engine)] = norm
|
|
236
|
+
_write(data)
|
|
237
|
+
return norm
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def unset_budget(engine: str) -> bool:
|
|
241
|
+
"""删除一条预算;返回是否真的删了。"""
|
|
242
|
+
data = _read_file()
|
|
243
|
+
if engine not in data:
|
|
244
|
+
return False
|
|
245
|
+
data.pop(engine, None)
|
|
246
|
+
_write(data)
|
|
247
|
+
return True
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""budget_cli.py — `argo budget`:按引擎的月度预算读写口。
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
argo budget list [--json]
|
|
6
|
+
argo budget set <engine> <amount> [--currency CNY|USD] [--period month|day]
|
|
7
|
+
argo budget unset <engine>
|
|
8
|
+
|
|
9
|
+
预算存在 ~/.config/argo/budgets.json(`ARGO_BUDGET_FILE` 可覆盖);`quota.py`
|
|
10
|
+
启动时读取,按引擎原生币种与调用成本比较。默认只有 DeepSeek ¥5/月。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import budget
|
|
19
|
+
from cli_io import dumps
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
23
|
+
items = budget.load_budgets()
|
|
24
|
+
try:
|
|
25
|
+
from quota import get_quota_manager
|
|
26
|
+
stats = get_quota_manager().get_stats()
|
|
27
|
+
except Exception:
|
|
28
|
+
stats = {}
|
|
29
|
+
rows = []
|
|
30
|
+
for engine, entry in sorted(items.items()):
|
|
31
|
+
st = stats.get(engine) or {}
|
|
32
|
+
rows.append({
|
|
33
|
+
"engine": engine,
|
|
34
|
+
"amount": entry["amount"],
|
|
35
|
+
"currency": entry["currency"],
|
|
36
|
+
"period": entry["period"],
|
|
37
|
+
"cost_used": st.get("cost_used"),
|
|
38
|
+
"cost_per_call": st.get("cost_per_call"),
|
|
39
|
+
"available": st.get("available"),
|
|
40
|
+
})
|
|
41
|
+
if args.json:
|
|
42
|
+
print(dumps({"file": str(budget.budget_file()), "budgets": rows}))
|
|
43
|
+
else:
|
|
44
|
+
print(f"预算文件:{budget.budget_file()}")
|
|
45
|
+
if not rows:
|
|
46
|
+
print("(无预算;引擎沿用 quota_profiles.json 的次数额度)")
|
|
47
|
+
for r in rows:
|
|
48
|
+
print(f" {r['engine']:<20} {r['amount']:>8.2f} {r['currency']}/"
|
|
49
|
+
f"{r['period']} 已用 {r['cost_used']} 可用={r['available']}")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_set(args: argparse.Namespace) -> int:
|
|
54
|
+
try:
|
|
55
|
+
entry = budget.set_budget(args.engine, args.amount,
|
|
56
|
+
currency=args.currency,
|
|
57
|
+
period=args.period)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
print(f"设置失败:{exc}", file=sys.stderr)
|
|
60
|
+
return 2
|
|
61
|
+
print(f"已设置 {args.engine}: {entry['amount']} {entry['currency']}/{entry['period']}"
|
|
62
|
+
f"({budget.budget_file()})")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cmd_unset(args: argparse.Namespace) -> int:
|
|
67
|
+
ok = budget.unset_budget(args.engine)
|
|
68
|
+
print(("已删除 " if ok else "未找到 ") + args.engine)
|
|
69
|
+
return 0 if ok else 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int:
|
|
73
|
+
ap = argparse.ArgumentParser(prog="argo budget",
|
|
74
|
+
description="按引擎的月度预算(原生币种)")
|
|
75
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
76
|
+
p_list = sub.add_parser("list", help="列出预算与本月已用")
|
|
77
|
+
p_list.add_argument("--json", action="store_true")
|
|
78
|
+
p_list.set_defaults(func=_cmd_list)
|
|
79
|
+
p_set = sub.add_parser("set", help="设置某引擎预算")
|
|
80
|
+
p_set.add_argument("engine")
|
|
81
|
+
p_set.add_argument("amount", type=float)
|
|
82
|
+
p_set.add_argument("--currency", default="CNY")
|
|
83
|
+
p_set.add_argument("--period", default="month", choices=["day", "month"])
|
|
84
|
+
p_set.set_defaults(func=_cmd_set)
|
|
85
|
+
p_unset = sub.add_parser("unset", help="删除某引擎预算")
|
|
86
|
+
p_unset.add_argument("engine")
|
|
87
|
+
p_unset.set_defaults(func=_cmd_unset)
|
|
88
|
+
args = ap.parse_args(argv)
|
|
89
|
+
return args.func(args)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
raise SystemExit(main())
|