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.
Files changed (472) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +735 -0
  3. package/README.es.md +777 -0
  4. package/README.ja.md +777 -0
  5. package/README.ko.md +777 -0
  6. package/README.md +778 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/why-better.svg +104 -0
  12. package/assets/readme/workflow.svg +83 -0
  13. package/backends/domain_profiles.json +2356 -96
  14. package/backends/engine_registry.yaml +3428 -503
  15. package/backends/query_synonyms_cn.json +35 -0
  16. package/backends/quota_profiles.json +2488 -160
  17. package/backends/source_types_cn.json +109 -0
  18. package/backends/text_lexicon.json +44 -0
  19. package/bin/argo +772 -0
  20. package/bin/argo.js +102 -39
  21. package/config.yaml +5139 -367
  22. package/cordis.patch.yml +21 -0
  23. package/data/countries.json +40 -0
  24. package/data/lang_matrix/lang_capability.json +842 -0
  25. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  26. package/data/regions_cn.json +1 -0
  27. package/docs/ADDING_NEW_ENGINE.md +184 -0
  28. package/docs/ARGO_INTRO.md +124 -0
  29. package/docs/DATA_SOURCE_GAPS.md +81 -0
  30. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  31. package/docs/ENGINE_CATALOG.md +575 -0
  32. package/docs/MCP_SETUP.md +108 -0
  33. package/docs/PLATFORM_PATHS.md +111 -0
  34. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  35. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  36. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  37. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  38. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  39. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  40. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  41. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  42. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  43. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  44. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  45. package/docs/SEARCH_ARCHIVE.md +115 -0
  46. package/docs/assets/architecture.svg +104 -0
  47. package/docs/assets/hero.svg +98 -0
  48. package/docs/engines/bangumi.md +27 -0
  49. package/docs/engines/biorxiv.md +27 -0
  50. package/docs/engines/clawhub.md +27 -0
  51. package/docs/engines/crt_sh.md +27 -0
  52. package/docs/engines/datacite.md +27 -0
  53. package/docs/engines/deps_dev.md +27 -0
  54. package/docs/engines/douban_movie.md +27 -0
  55. package/docs/engines/endoflife.md +27 -0
  56. package/docs/engines/flk_law.md +27 -0
  57. package/docs/engines/fxtwitter.md +27 -0
  58. package/docs/engines/google_news.md +27 -0
  59. package/docs/engines/gov_regulations.md +27 -0
  60. package/docs/engines/guqian.md +27 -0
  61. package/docs/engines/hackernews.md +27 -0
  62. package/docs/engines/iplant.md +27 -0
  63. package/docs/engines/keenable.md +27 -0
  64. package/docs/engines/met_museum.md +27 -0
  65. package/docs/engines/nasa_images.md +27 -0
  66. package/docs/engines/netease_music.md +27 -0
  67. package/docs/engines/npm.md +27 -0
  68. package/docs/engines/nvd.md +27 -0
  69. package/docs/engines/openreview.md +27 -0
  70. package/docs/engines/openstd.md +27 -0
  71. package/docs/engines/parallel_free.md +27 -0
  72. package/docs/engines/people_daily.md +27 -0
  73. package/docs/engines/realtime_index.md +27 -0
  74. package/docs/engines/sec_edgar.md +27 -0
  75. package/docs/engines/seltz.md +27 -0
  76. package/docs/engines/skillsmp.md +27 -0
  77. package/docs/engines/sspai.md +27 -0
  78. package/docs/engines/std_samr.md +27 -0
  79. package/docs/engines/train.md +27 -0
  80. package/docs/engines/un_comtrade.md +27 -0
  81. package/docs/engines/wikisource.md +27 -0
  82. package/docs/engines/zdic.md +27 -0
  83. package/docs/engines/zenodo.md +27 -0
  84. package/docs/engines/zhihu_hot_app.md +27 -0
  85. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  86. package/engines/README.md +10 -0
  87. package/engines/_template_cli.yaml +39 -0
  88. package/engines/_template_http.yaml +37 -0
  89. package/engines/plugins/_template_plugin.py +50 -0
  90. package/engines/specs/_cisa_kev.yaml +54 -0
  91. package/engines/specs/_osv.yaml +63 -0
  92. package/engines/specs/aljazeera.yaml +43 -0
  93. package/engines/specs/antara.yaml +43 -0
  94. package/engines/specs/artic.yaml +20 -0
  95. package/engines/specs/aviation_weather.yaml +39 -0
  96. package/engines/specs/bangumi.yaml +21 -0
  97. package/engines/specs/biorxiv.yaml +24 -0
  98. package/engines/specs/carbon_intensity.yaml +32 -0
  99. package/engines/specs/cfets.yaml +41 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/defillama.yaml +38 -0
  113. package/engines/specs/deps_dev.yaml +21 -0
  114. package/engines/specs/digup.yaml +36 -0
  115. package/engines/specs/doi.yaml +39 -0
  116. package/engines/specs/douban_movie.yaml +22 -0
  117. package/engines/specs/dw_news.yaml +47 -0
  118. package/engines/specs/egov_law.yaml +21 -0
  119. package/engines/specs/elpais.yaml +43 -0
  120. package/engines/specs/endoflife.yaml +21 -0
  121. package/engines/specs/energy_charts.yaml +21 -0
  122. package/engines/specs/factcheck_org.yaml +52 -0
  123. package/engines/specs/faz.yaml +43 -0
  124. package/engines/specs/federal_register.yaml +57 -0
  125. package/engines/specs/firecrawl.yaml +53 -0
  126. package/engines/specs/flk_law.yaml +24 -0
  127. package/engines/specs/folha.yaml +43 -0
  128. package/engines/specs/france24.yaml +44 -0
  129. package/engines/specs/full_fact.yaml +47 -0
  130. package/engines/specs/fxtwitter.yaml +36 -0
  131. package/engines/specs/gbfs_nyc.yaml +32 -0
  132. package/engines/specs/gdacs.yaml +21 -0
  133. package/engines/specs/gdelt.yaml +64 -0
  134. package/engines/specs/gleif.yaml +55 -0
  135. package/engines/specs/google_news.yaml +22 -0
  136. package/engines/specs/gov_regulations.yaml +38 -0
  137. package/engines/specs/guardian_rss.yaml +59 -0
  138. package/engines/specs/hkex.yaml +40 -0
  139. package/engines/specs/hurriyet.yaml +43 -0
  140. package/engines/specs/iplant.yaml +25 -0
  141. package/engines/specs/jikan.yaml +24 -0
  142. package/engines/specs/jolpica.yaml +20 -0
  143. package/engines/specs/k10plus.yaml +20 -0
  144. package/engines/specs/keenable.yaml +44 -0
  145. package/engines/specs/keenable_free.yaml +36 -0
  146. package/engines/specs/lefigaro.yaml +43 -0
  147. package/engines/specs/listenbrainz.yaml +19 -0
  148. package/engines/specs/matichon.yaml +43 -0
  149. package/engines/specs/met_museum.yaml +21 -0
  150. package/engines/specs/nasa_images.yaml +45 -0
  151. package/engines/specs/netease_music.yaml +38 -0
  152. package/engines/specs/nhk.yaml +43 -0
  153. package/engines/specs/nhtsa_vpic.yaml +20 -0
  154. package/engines/specs/noaa_swpc.yaml +20 -0
  155. package/engines/specs/nvd.yaml +38 -0
  156. package/engines/specs/obis.yaml +20 -0
  157. package/engines/specs/opencitations.yaml +52 -0
  158. package/engines/specs/openf1.yaml +21 -0
  159. package/engines/specs/openfoodfacts.yaml +45 -0
  160. package/engines/specs/openligadb.yaml +20 -0
  161. package/engines/specs/openreview.yaml +37 -0
  162. package/engines/specs/openstd.yaml +21 -0
  163. package/engines/specs/people_daily.yaml +22 -0
  164. package/engines/specs/realtime_index.yaml +34 -0
  165. package/engines/specs/ror.yaml +20 -0
  166. package/engines/specs/satnogs.yaml +20 -0
  167. package/engines/specs/sec_edgar.yaml +46 -0
  168. package/engines/specs/shfe.yaml +39 -0
  169. package/engines/specs/skillsmp.yaml +50 -0
  170. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  171. package/engines/specs/soilgrids.yaml +25 -0
  172. package/engines/specs/sspai.yaml +36 -0
  173. package/engines/specs/stackexchange.yaml +46 -0
  174. package/engines/specs/std_samr.yaml +21 -0
  175. package/engines/specs/tass.yaml +43 -0
  176. package/engines/specs/tavily_free.yaml +56 -0
  177. package/engines/specs/tle_mirror.yaml +21 -0
  178. package/engines/specs/train.yaml +37 -0
  179. package/engines/specs/tvmaze.yaml +20 -0
  180. package/engines/specs/twitter_syndication.yaml +31 -0
  181. package/engines/specs/un_comtrade.yaml +23 -0
  182. package/engines/specs/unpaywall.yaml +54 -0
  183. package/engines/specs/vnpress.yaml +43 -0
  184. package/engines/specs/weather.yaml +32 -0
  185. package/engines/specs/who_don.yaml +21 -0
  186. package/engines/specs/who_gho.yaml +20 -0
  187. package/engines/specs/wikimedia_commons.yaml +34 -0
  188. package/engines/specs/wikisource.yaml +22 -0
  189. package/engines/specs/worms.yaml +19 -0
  190. package/engines/specs/x_trending.yaml +34 -0
  191. package/engines/specs/yna.yaml +43 -0
  192. package/engines/specs/ynet.yaml +43 -0
  193. package/engines/specs/yuanbao.yaml +93 -0
  194. package/engines/specs/zbmath.yaml +40 -0
  195. package/engines/specs/zdic.yaml +22 -0
  196. package/engines/specs/zenodo.yaml +27 -0
  197. package/engines/specs/zhihu_hot_app.yaml +35 -0
  198. package/mcp/clients.yaml +106 -0
  199. package/package.json +55 -12
  200. package/packages/dsh-plugin/README.md +152 -0
  201. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  202. package/packages/dsh-plugin/dsh/client.js +1533 -0
  203. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  204. package/packages/dsh-plugin/dsh/index.js +527 -0
  205. package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
  206. package/packages/dsh-plugin/dsh/runner.js +666 -0
  207. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  208. package/packages/dsh-plugin/package.json +38 -0
  209. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  210. package/packages/dsh-plugin/test/client.test.js +406 -0
  211. package/packages/dsh-plugin/test/config.test.js +30 -0
  212. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  213. package/packages/dsh-plugin/test/index.test.js +1382 -0
  214. package/references/query-templates.md +96 -0
  215. package/references/query-templates.yaml +118 -0
  216. package/scripts/ab_eval.py +219 -0
  217. package/scripts/ab_eval_p0p1.py +266 -0
  218. package/scripts/adaptive.py +294 -70
  219. package/scripts/answer.py +154 -0
  220. package/scripts/archive_run.py +591 -0
  221. package/scripts/argo_engine_registry.py +172 -0
  222. package/scripts/argo_paths.py +728 -0
  223. package/scripts/article.py +136 -0
  224. package/scripts/article_meta.py +131 -0
  225. package/scripts/arxiv_source.py +201 -0
  226. package/scripts/badcase_ledger.py +252 -0
  227. package/scripts/batch_probe.py +241 -0
  228. package/scripts/benchmark.py +19 -0
  229. package/scripts/bounded_run.py +144 -0
  230. package/scripts/browser_auth.py +323 -0
  231. package/scripts/budget.py +247 -0
  232. package/scripts/budget_cli.py +93 -0
  233. package/scripts/build_release_assets.sh +65 -0
  234. package/scripts/burstiness_detector.py +147 -0
  235. package/scripts/cache.py +1066 -67
  236. package/scripts/cache_guard.py +271 -0
  237. package/scripts/cache_key_vdom.py +56 -0
  238. package/scripts/cache_similarity.py +170 -0
  239. package/scripts/caliber_isolation.py +195 -0
  240. package/scripts/calibrate_lowquality.py +210 -0
  241. package/scripts/candidate_envelope.py +366 -0
  242. package/scripts/chrome_cdp.py +871 -0
  243. package/scripts/ci_drift_gate.py +90 -0
  244. package/scripts/circuit_breaker.py +389 -0
  245. package/scripts/citations.py +334 -0
  246. package/scripts/cite_cli.py +59 -0
  247. package/scripts/cjk_tokens.py +95 -0
  248. package/scripts/clarify.py +47 -24
  249. package/scripts/cli_io.py +240 -0
  250. package/scripts/config.py +683 -45
  251. package/scripts/config_cache.py +402 -0
  252. package/scripts/conn_pool.py +220 -0
  253. package/scripts/content_boundary.py +46 -0
  254. package/scripts/content_security.py +685 -0
  255. package/scripts/content_signals.py +751 -0
  256. package/scripts/crawl.py +74 -35
  257. package/scripts/cross_lingual_trace.py +293 -0
  258. package/scripts/daemon.py +866 -0
  259. package/scripts/digup_bridge.py +214 -0
  260. package/scripts/dispatch_result.py +98 -0
  261. package/scripts/engine_admission.py +366 -0
  262. package/scripts/engine_dispatch.py +982 -0
  263. package/scripts/engine_env.py +539 -0
  264. package/scripts/engine_failure.py +411 -0
  265. package/scripts/engine_families.py +738 -0
  266. package/scripts/engine_policy.py +218 -0
  267. package/scripts/engine_requires.py +190 -0
  268. package/scripts/engine_status.py +322 -0
  269. package/scripts/engine_validate.py +638 -0
  270. package/scripts/engines.py +799 -294
  271. package/scripts/engines_base.py +1521 -0
  272. package/scripts/engines_builders.py +310 -0
  273. package/scripts/engines_builders_batch10.py +204 -0
  274. package/scripts/engines_builders_batch11.py +174 -0
  275. package/scripts/engines_builders_batch12.py +103 -0
  276. package/scripts/engines_builders_batch13.py +340 -0
  277. package/scripts/engines_builders_batch14.py +656 -0
  278. package/scripts/engines_builders_batch15.py +249 -0
  279. package/scripts/engines_builders_batch9.py +1468 -0
  280. package/scripts/engines_builders_cn.py +2283 -0
  281. package/scripts/engines_builders_data.py +2923 -0
  282. package/scripts/engines_builders_data_macro.py +775 -0
  283. package/scripts/engines_builders_feeds.py +255 -0
  284. package/scripts/engines_builders_intl.py +1239 -0
  285. package/scripts/engines_builders_keyless.py +111 -0
  286. package/scripts/engines_builders_search.py +440 -0
  287. package/scripts/engines_builders_tech.py +1359 -0
  288. package/scripts/evidence.py +620 -166
  289. package/scripts/evidence_loop.py +624 -0
  290. package/scripts/evidence_tier.py +298 -0
  291. package/scripts/except_sets.py +25 -0
  292. package/scripts/extract.py +55 -16
  293. package/scripts/fact_align.py +207 -0
  294. package/scripts/fact_crosscheck.py +230 -0
  295. package/scripts/fetch.py +25 -12
  296. package/scripts/fetch_browser.py +87 -0
  297. package/scripts/fetch_jsrun.py +122 -0
  298. package/scripts/fetch_quality.py +225 -0
  299. package/scripts/fetch_render_tinyfish.py +117 -0
  300. package/scripts/fetch_v3.py +1804 -0
  301. package/scripts/focus_extract.py +350 -0
  302. package/scripts/fulltext_store.py +169 -0
  303. package/scripts/fxtwitter_api.py +325 -0
  304. package/scripts/gen_engine_catalog.py +364 -0
  305. package/scripts/gen_native_tools.py +91 -0
  306. package/scripts/gen_routing_snapshot.py +195 -0
  307. package/scripts/health_check.py +140 -402
  308. package/scripts/health_probe.py +23 -8
  309. package/scripts/hot_state.py +134 -0
  310. package/scripts/http_client.py +1053 -0
  311. package/scripts/image_ops.py +430 -0
  312. package/scripts/image_vision/vision_probe.swift +126 -0
  313. package/scripts/install.ps1 +166 -0
  314. package/scripts/install.sh +221 -0
  315. package/scripts/job.py +1186 -0
  316. package/scripts/jump_resolver.py +235 -0
  317. package/scripts/lang_capability.py +150 -0
  318. package/scripts/lang_detect.py +289 -0
  319. package/scripts/lang_matrix_probe.py +329 -0
  320. package/scripts/lang_pref.py +311 -0
  321. package/scripts/link_source.py +340 -0
  322. package/scripts/local_image.py +970 -0
  323. package/scripts/local_seek.py +402 -0
  324. package/scripts/macro_countries.py +97 -0
  325. package/scripts/matrix_search_eval.py +1187 -0
  326. package/scripts/mcp_compact.py +234 -0
  327. package/scripts/mcp_diag.py +8 -3
  328. package/scripts/mcp_handlers.py +492 -0
  329. package/scripts/mcp_handlers_surface.py +157 -0
  330. package/scripts/mcp_launch.sh +124 -0
  331. package/scripts/mcp_local.py +241 -0
  332. package/scripts/mcp_server.py +125 -419
  333. package/scripts/mcp_setup.py +431 -0
  334. package/scripts/mcp_tools.py +331 -0
  335. package/scripts/mcp_transport.py +191 -0
  336. package/scripts/mcp_warm.py +127 -0
  337. package/scripts/min_version_scan.py +335 -0
  338. package/scripts/net_proxy.py +258 -0
  339. package/scripts/network_aware.py +152 -0
  340. package/scripts/output_transparency.py +200 -0
  341. package/scripts/paid_policy.py +98 -0
  342. package/scripts/paper.py +274 -0
  343. package/scripts/pdf_extract.py +518 -0
  344. package/scripts/phrase_filter.py +66 -0
  345. package/scripts/plan.py +422 -0
  346. package/scripts/postprocess_signals.py +150 -0
  347. package/scripts/pre-commit +48 -0
  348. package/scripts/query_enhance.py +157 -0
  349. package/scripts/query_operators.py +196 -0
  350. package/scripts/query_rewriter.py +116 -7
  351. package/scripts/query_signals.py +300 -0
  352. package/scripts/query_templates.py +167 -0
  353. package/scripts/query_understanding.py +411 -0
  354. package/scripts/query_variants.py +298 -0
  355. package/scripts/quota.py +633 -76
  356. package/scripts/rank_signals.py +775 -0
  357. package/scripts/ranking_eval.py +319 -0
  358. package/scripts/readability_extract.py +433 -0
  359. package/scripts/recompute.py +415 -0
  360. package/scripts/recovery.py +616 -0
  361. package/scripts/recovery_l0.py +160 -0
  362. package/scripts/recovery_runner.py +150 -0
  363. package/scripts/redskill/redskill_engine.py +332 -0
  364. package/scripts/regression_p0p1.py +318 -0
  365. package/scripts/replay_eval.py +658 -0
  366. package/scripts/rerank_tfidf.py +225 -0
  367. package/scripts/research.py +607 -403
  368. package/scripts/research_cli.py +361 -0
  369. package/scripts/research_dossier.py +492 -0
  370. package/scripts/research_expand.py +169 -0
  371. package/scripts/research_gates.py +272 -0
  372. package/scripts/research_report.py +209 -0
  373. package/scripts/research_strategy.py +37 -0
  374. package/scripts/research_work_packages.py +195 -0
  375. package/scripts/result_lang.py +311 -0
  376. package/scripts/retry_policy.py +76 -0
  377. package/scripts/robots_guard.py +259 -0
  378. package/scripts/route.py +921 -254
  379. package/scripts/route_bangs.py +40 -0
  380. package/scripts/route_cache.py +243 -0
  381. package/scripts/route_combo.py +917 -0
  382. package/scripts/route_domains.py +341 -0
  383. package/scripts/route_lang.py +605 -0
  384. package/scripts/route_log.py +63 -0
  385. package/scripts/route_policy.py +250 -0
  386. package/scripts/rrf_classic.py +66 -0
  387. package/scripts/screenshot.py +73 -0
  388. package/scripts/search.py +886 -406
  389. package/scripts/search_attribution.py +185 -0
  390. package/scripts/search_benchmark.py +407 -0
  391. package/scripts/search_cli.py +640 -0
  392. package/scripts/search_entry.py +395 -0
  393. package/scripts/search_output.py +690 -0
  394. package/scripts/search_pipeline.py +926 -0
  395. package/scripts/search_rank.py +991 -0
  396. package/scripts/search_types.py +129 -16
  397. package/scripts/seek_locator.py +68 -0
  398. package/scripts/semantic_evidence.py +391 -0
  399. package/scripts/serp_guard.py +336 -0
  400. package/scripts/serp_kill_log.py +74 -0
  401. package/scripts/single_flight.py +120 -0
  402. package/scripts/social_engines/__init__.py +10 -0
  403. package/scripts/social_engines/bilibili_engine.py +131 -0
  404. package/scripts/social_engines/hackernews_engine.py +113 -0
  405. package/scripts/social_engines/reddit_engine.py +172 -0
  406. package/scripts/social_engines/twitter_engine.py +397 -0
  407. package/scripts/social_engines/v2ex_engine.py +101 -0
  408. package/scripts/social_engines/weibo_engine.py +128 -0
  409. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  410. package/scripts/social_engines/zhihu_engine.py +139 -0
  411. package/scripts/social_research.py +201 -0
  412. package/scripts/sogou_weixin_browser.py +318 -0
  413. package/scripts/source_ledger.py +249 -0
  414. package/scripts/stage_timing.py +58 -0
  415. package/scripts/stats_cli.py +107 -0
  416. package/scripts/straggler_policy.py +60 -0
  417. package/scripts/stylometry_detector.py +231 -0
  418. package/scripts/sync_backends.py +353 -0
  419. package/scripts/sync_installs.py +29 -0
  420. package/scripts/template_cli.py +127 -0
  421. package/scripts/text_lexicon.py +72 -0
  422. package/scripts/tfidf_router.py +109 -37
  423. package/scripts/time_utils.py +233 -0
  424. package/scripts/topic_research_profiles.py +643 -0
  425. package/scripts/train.py +737 -0
  426. package/scripts/tweet.py +596 -0
  427. package/scripts/url_canon.py +217 -0
  428. package/scripts/url_optimize.py +56 -0
  429. package/scripts/url_safety.py +236 -0
  430. package/scripts/usage_log.py +116 -0
  431. package/scripts/usage_log_clean.py +113 -0
  432. package/scripts/v2ex_nodes.py +311 -0
  433. package/scripts/variant_recall.py +188 -0
  434. package/scripts/watch.py +205 -0
  435. package/scripts/wechat_article.py +258 -0
  436. package/scripts/wide_research.py +447 -0
  437. package/scripts/wx.py +349 -0
  438. package/scripts/x_article_fetch.py +141 -0
  439. package/scripts/x_search.py +234 -0
  440. package/scripts/x_trending.py +189 -0
  441. package/scripts/yaml_load.py +67 -0
  442. package/scripts/zhihu_quota.py +139 -0
  443. package/sub-skills/ego-search/SKILL.md +253 -0
  444. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  445. package/sub-skills/ego-search/scripts/merge.py +226 -0
  446. package/sub-skills/ego-search/scripts/quality.py +64 -0
  447. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  448. package/sub-skills/ego-search/scripts/safety.py +109 -0
  449. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  450. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  451. package/sub-skills/js-run/SKILL.md +67 -0
  452. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  453. package/sub-skills/js-run/scripts/shim.js +188 -0
  454. package/sub-skills/local-search/SKILL.md +50 -53
  455. package/sub-skills/local-search/bm25.py +186 -0
  456. package/sub-skills/local-search/cli_runner.py +331 -0
  457. package/sub-skills/local-search/config.yaml +152 -55
  458. package/sub-skills/local-search/engine_registry.py +91 -26
  459. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  460. package/sub-skills/local-search/local_search_adapter.py +13 -2
  461. package/sub-skills/local-search/parse_maps.yaml +40 -42
  462. package/sub-skills/local-search/parsers.py +328 -0
  463. package/sub-skills/local-search/search_v3.py +716 -242
  464. package/sub-skills/local-search/smart_router.py +47 -7
  465. package/sub-skills/local-seek/SKILL.md +110 -0
  466. package/sub-skills/local-seek/config/domains.yaml +20 -0
  467. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  468. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  469. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  470. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  471. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  472. package/sub-skills/local-seek/scripts/seek_rank.py +197 -0
package/scripts/config.py CHANGED
@@ -7,19 +7,62 @@ config.py — Unified Search v2 配置加载器
7
7
  - 支持热加载(按 mtime 缓存)
8
8
  - 使用 PyYAML 解析(缺失时给出明确安装提示)
9
9
  - 将 ~ 展开为实际用户目录
10
+ - 合并 engines/*.yaml 外置引擎声明
11
+ - 合并 config.local.yaml 本机覆盖层(gitignore,本机状态不进公开仓)
10
12
  - 提供类型化访问接口(引擎列表、域规则、成本分级、预算配置)
11
13
  """
12
14
 
13
15
  from __future__ import annotations
14
16
 
17
+ import hashlib
15
18
  import json
16
19
  import os
20
+ import time
17
21
  from pathlib import Path
18
- from typing import Any
22
+ from typing import Any, Callable
19
23
 
20
24
  # ── 路径 ──────────────────────────────────────────────────────────────────────
21
25
 
26
+ # logging 延迟导入(已实施):config.py 的 import 链实测 23ms,其中 logging 占 ~10ms。
27
+ # 全文件仅 1 处 warning 使用(routable_only 过滤降级留痕),在该函数内 import,
28
+ # 模块级导入会让所有 import config 的路径白付 ~10ms/次。
29
+ # shutil 同样惰性化:唯一用点是 _validate_engine_paths 的 shutil.which(裸命令
30
+ # PATH 探测),模块级 import 会让每个进程(含纯缓存命中的搜索)白付 ~3ms,
31
+ # 且拖着 zlib/bz2/lzma。测试对 patch.object(config_mod.shutil, ...) 的依赖
32
+ # 由模块 __getattr__ 保持(见文件末尾)——惰性导入与可打桩性不再二选一。
33
+
22
34
  CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
35
+ # 本机覆盖层:与 config.yaml 同目录的 config.local.yaml(**gitignore**)。
36
+ # 只写「要改的键」,深度合并进最终配置——本机状态(账户欠费临时停用某引擎、
37
+ # 本机路径、本机调优)落在这里,不随公开仓发版。详见 _merge_local_config。
38
+ LOCAL_CONFIG_PATH = Path(__file__).parent.parent / "config.local.yaml"
39
+ # 外置引擎声明目录:engines/*.yaml(不含 plugins/、templates/、_ 前缀)
40
+ ENGINES_DIR = Path(__file__).parent.parent / "engines"
41
+
42
+ # 本地状态目录唯一来源。argo_paths 只在函数体内反向 import config,
43
+ # 因此此处模块级导入不会成环(config 未就绪时 argo_paths 会回落到历史目录)。
44
+ import argo_paths # noqa: E402
45
+
46
+ # 跨进程配置磁盘缓存(性能层)住在 config_cache;这里同名转出:load_config /
47
+ # peek_cache_db_path / _cli 与既有测试(config._config_content_digest …)无需改。
48
+ # 打桩点若落在这些符号上,必须打在**读取处**(config_cache)。
49
+ from config_cache import ( # noqa: E402
50
+ _CONFIG_DISK_CACHE_SCHEMA,
51
+ _config_disk_cache_enabled,
52
+ _config_disk_cache_path,
53
+ _config_disk_cache_key,
54
+ _config_db_path_key,
55
+ _config_content_digest,
56
+ _disk_cache_payload,
57
+ _json_round_trip_safe,
58
+ _load_config_disk_cache,
59
+ _looks_like_config,
60
+ _loader_sig,
61
+ _peek_disk_cache_db_path,
62
+ _save_config_disk_cache,
63
+ _sweep_orphan_cache_slots,
64
+ )
65
+ from cli_io import dumps
23
66
 
24
67
 
25
68
  # ── 默认配置 ──────────────────────────────────────────────────────────────────
@@ -28,8 +71,10 @@ DEFAULT_CONFIG: dict[str, Any] = {
28
71
  "version": 2,
29
72
  "engines": {
30
73
  "anysearch": {
31
- "enabled": True, "type": "cli",
32
- "cmd": ["python3", "~/.agents/skills/anysearch-skill/scripts/anysearch_cli.py"],
74
+ "enabled": True, "type": "anysearch",
75
+ # 进程内 JSON-RPC builder(保持一致 config.yaml 来源);不调用主机上的
76
+ # anysearch-skill CLI,避免写死 ~/.agents/skills 主机路径(纪律:禁止)。
77
+ "label": "AnySearch", "cost_tier": "free",
33
78
  "search_args": ["search", "{query}", "--max_results", "{n}"],
34
79
  "env": {},
35
80
  },
@@ -41,9 +86,10 @@ DEFAULT_CONFIG: dict[str, Any] = {
41
86
  "fallback": "anysearch", "parallel": True,
42
87
  },
43
88
  ],
44
- "cache": {"enabled": True, "db_path": "~/.cache/unified-search/cache.db", "ttl": 3600, "max_size_mb": 200},
45
- "execution": {"default_timeout": 8, "parallel_timeout": 6, "max_parallel_engines": 3, "retry_count": 0},
46
- "cost_tiers": {"free": ["anysearch"], "low": [], "paid": []},
89
+ # db_path 由 argo_paths 唯一来源派生,不再字面量拼 ~/.cache/unified-search。
90
+ # 无 ARGO_STATE_DIR 时展开结果与历史默认一致,存量缓存不失效。
91
+ "cache": {"enabled": True, "db_path": str(argo_paths.db_path()), "ttl": 3600, "max_size_mb": 200},
92
+ "execution": {"default_timeout": 8, "max_parallel_engines": 6, "retry_count": 0},
47
93
  "budget": {
48
94
  "fast": {"max_cost_per_query": 0.0, "allow_paid": False},
49
95
  "auto": {"max_cost_per_query": 0.01, "allow_paid": True},
@@ -65,8 +111,14 @@ def _require_yaml():
65
111
 
66
112
 
67
113
  def _load_yaml(text: str) -> dict[str, Any]:
68
- yaml = _require_yaml()
69
- parsed = yaml.safe_load(text)
114
+ """解析 YAML —— 统一走 yaml_load(优先 libyaml C 版 loader)。
115
+
116
+ 此前直接调 `yaml.safe_load`,走的是纯 Python 扫描器:解析 123 KB 的
117
+ config.yaml 实测 79 ms,C 版同内容 10 ms。冷启动链上同一份配置会被解析
118
+ 多次,差距被成倍放大。详见 yaml_load.py。
119
+ """
120
+ from yaml_load import loads
121
+ parsed = loads(text)
70
122
  return parsed if isinstance(parsed, dict) else {}
71
123
 
72
124
 
@@ -115,9 +167,16 @@ def _resolve_relative_paths(config: dict[str, Any]) -> dict[str, Any]:
115
167
 
116
168
 
117
169
  def _validate_engine_paths(config: dict[str, Any]) -> dict[str, Any]:
118
- """验证引擎 CLI 路径,不存在则标记为 disabled。"""
119
- import logging as _logging
120
- _log = _logging.getLogger("unified_search.config")
170
+ """验证引擎 CLI 路径,不存在则标记为 disabled。
171
+
172
+ 相对路径一律以 config.yaml 所在目录为基准解析(不是进程 CWD)。外置 spec
173
+ (engines/specs/*.yaml)的 cmd 是相对路径,而 _resolve_relative_paths 在
174
+ 合并外置声明之前就已执行,故这些路径不会被转成绝对路径。若这里用
175
+ Path(cmd[-1]).exists() 判定,结果就随 CWD 变——实测同一份配置在仓库根
176
+ 目录下 165 个引擎可用、在 /tmp 下只剩 163(train/weather 被静默停用),
177
+ 一致性检查也随之红/绿漂移。
178
+ """
179
+ base = CONFIG_PATH.parent
121
180
  for name, spec in config.get("engines", {}).items():
122
181
  if not isinstance(spec, dict) or spec.get("type") != "cli":
123
182
  continue
@@ -128,60 +187,507 @@ def _validate_engine_paths(config: dict[str, Any]) -> dict[str, Any]:
128
187
  if cmd_path_str.startswith("--"):
129
188
  continue
130
189
  cmd_path = Path(cmd_path_str).expanduser()
131
- if not cmd_path.exists():
190
+ if not cmd_path.is_absolute():
191
+ cmd_path = base / cmd_path
192
+ if cmd_path.exists():
193
+ continue
194
+ # 裸命令(如 PATH 中的可执行文件):用 shutil.which 查 PATH,查不到才禁用
195
+ import shutil # 惰性导入:见顶部注释;__getattr__ 保持 config_mod.shutil 可打桩
196
+ if not (shutil.which(cmd_path_str) or shutil.which(cmd_path.name)):
132
197
  spec["enabled"] = False
133
198
  return config
134
199
 
135
200
 
136
- def load_config(force: bool = False) -> dict[str, Any]:
137
- """加载配置,支持热加载。"""
138
- global _config_cache, _config_mtime, _config_load_error
201
+ def _load_external_engine_specs() -> dict[str, dict[str, Any]]:
202
+ """加载 engines/*.yaml 与 engines/specs/*.yaml 外置声明。
203
+
204
+ 文件名(去后缀)默认为 engine_id;文件内 engine_id / name 可覆盖。
205
+ 跳过:_ 前缀文件。
206
+ """
207
+ result: dict[str, dict[str, Any]] = {}
208
+ if not ENGINES_DIR.is_dir():
209
+ return result
210
+ try:
211
+ yaml = _require_yaml()
212
+ except ImportError:
213
+ return result
214
+ from yaml_load import loads as _yaml_loads
215
+
216
+ def _load_dir(directory: Path) -> None:
217
+ if not directory.is_dir():
218
+ return
219
+ for path in sorted(directory.glob("*.yaml")):
220
+ if path.name.startswith("_"):
221
+ continue
222
+ try:
223
+ data = _yaml_loads(path.read_text(encoding="utf-8")) or {}
224
+ except Exception:
225
+ continue
226
+ if not isinstance(data, dict):
227
+ continue
228
+ # 允许 {engine_id, ...} 或直接是 engine spec
229
+ engine_id = data.get("engine_id") or data.get("name") or path.stem
230
+ if not isinstance(engine_id, str) or not engine_id:
231
+ continue
232
+ spec = dict(data)
233
+ spec.pop("engine_id", None)
234
+ # name 字段若是展示名且与 id 不同,保留;type 必须有
235
+ if "type" not in spec and "url" in spec:
236
+ spec["type"] = "http"
237
+ if "enabled" not in spec:
238
+ spec["enabled"] = True
239
+ spec["_external_spec"] = str(path)
240
+ result[engine_id] = spec
241
+
242
+ _load_dir(ENGINES_DIR)
243
+ _load_dir(ENGINES_DIR / "specs")
244
+ return result
245
+
246
+
247
+ def _merge_external_engines(config: dict[str, Any]) -> dict[str, Any]:
248
+ """外置 YAML 覆盖/新增引擎;cost_tiers 可选追加。"""
249
+ external = _load_external_engine_specs()
250
+ if not external:
251
+ return config
252
+ engines = config.setdefault("engines", {})
253
+ if not isinstance(engines, dict):
254
+ engines = {}
255
+ config["engines"] = engines
256
+ for eid, spec in external.items():
257
+ # 外置优先覆盖同名(便于本地试验)
258
+ base = dict(engines.get(eid) or {})
259
+ base.update(spec)
260
+ engines[eid] = base
261
+ # 注:不再维护独立的 cost_tiers 段。成本分级由 get_cost_tiers() 从 engines
262
+ # 段的 cost_tier 字段聚合,独立段是第二份计算方式(曾与声明矛盾:zhihu_global
263
+ # 列在 free 而声明 api、tavily 列在 paid 而声明 api),且无任何读取方。
264
+ return config
265
+
266
+
267
+ def _load_local_config() -> dict[str, Any]:
268
+ """读本机覆盖层 config.local.yaml;不存在 / 读不动 / 损坏一律返回 {}。
269
+
270
+ fail-open 是刻意的:覆盖层是可选的加速器,它的缺失或损坏不该让整条配置链
271
+ 崩掉(与 cache 的降级、config 的 DEFAULT_CONFIG 回退同一口径)。损坏时
272
+ 静默忽略而不是抛,是为了不把「本机一个手滑的 YAML」升级成「所有命令都跑
273
+ 不了」——真要排查时看 last_load_error 之外没有提示,所以这里连告警都不发,
274
+ 保持与「覆盖层不存在」完全同一条路径(区分它们没有运维价值)。
275
+ """
276
+ if not LOCAL_CONFIG_PATH.is_file():
277
+ return {}
278
+ try:
279
+ text = LOCAL_CONFIG_PATH.read_text(encoding="utf-8")
280
+ except OSError:
281
+ return {}
282
+ try:
283
+ data = _load_yaml(text)
284
+ except Exception:
285
+ return {}
286
+ return data if isinstance(data, dict) else {}
287
+
288
+
289
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
290
+ """override **深度**覆盖 base:dict 逐键递归,其余(含 list)整体替换。
291
+
292
+ 与 _merge_external_engines 的**浅**覆盖(`base.update(spec)`)刻意不同:
293
+ 外置 spec 是「整份引擎声明」(浅覆盖才能整份换掉),本机覆盖层是「只写要
294
+ 改的键」(深合并才能让 `engines.parallel.enabled: false` 只翻一个布尔而不
295
+ 把整个 parallel 声明换成三行)。两种覆盖语义不同,各留一处实现,别合并。
296
+ """
297
+ out = dict(base)
298
+ for key, value in override.items():
299
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
300
+ out[key] = _deep_merge(out[key], value)
301
+ else:
302
+ out[key] = value
303
+ return out
304
+
305
+
306
+ def _merge_local_config(config: dict[str, Any]) -> dict[str, Any]:
307
+ """把本机覆盖层并进配置;无覆盖层时原样返回(不复制,省一份 220 引擎的拷贝)。"""
308
+ local = _load_local_config()
309
+ return _deep_merge(config, local) if local else config
310
+
311
+
312
+ _ext_scan_cache: tuple[float, tuple[float, int, int, str]] | None = None # (monotonic 时刻, 指纹)
313
+
314
+
315
+ def _external_engines_scan_uncached() -> tuple[float, int, int, str]:
316
+ """扫一遍外置声明目录 → (最新 mtime, 文件数, 总字节, 声明集摘要)。
317
+
318
+ 为什么不能只报 mtime:**max 对「删除」与「回填旧时间」是盲的**——删掉一个
319
+ 不是最新的声明(engines/specs/train.yaml 之类),max 不变;`cp -p` 拷进来
320
+ 一个新声明(mtime 被保留成旧值),max 也不变。两者都会让写入文件配置缓存继续
321
+ 命中,表现为「引擎删了还在 / 加了不生效」(审计实测)。文件数与总字节对
322
+ 集合变化敏感;摘要按**每个文件**的 (相对路径, 大小, mtime_ns, ctime_ns)
323
+ 聚合成一个值,能识别「总量不变但换了文件」这类集合替换。
324
+
325
+ 这里刻意**不读文件内容**:65 个声明逐个 read_bytes 实测 9.5 ms,等于把省下
326
+ 的时间吃掉一半。跨平台语义保底交给 config.yaml 的内容摘要(它是决定状态
327
+ 目录的那份文件,见 _config_content_digest):Windows 上 st_ctime 是创建
328
+ 时间而非元数据变更时间,本摘要因此退化为「路径集 + 大小 + mtime」,仍能
329
+ 覆盖编辑器改文件(mtime 变)与增删声明;只有「改写内容并还原 mtime」这种
330
+ 刻意手法在 Windows 上漏检,而 POSIX 上 ctime 仍然兜着。
331
+ """
332
+ latest = 0.0
333
+ count = 0
334
+ total = 0
335
+ digest = hashlib.blake2b(digest_size=16)
336
+ if not ENGINES_DIR.is_dir():
337
+ return latest, count, total, digest.hexdigest()
338
+ for path in sorted(ENGINES_DIR.rglob("*.yaml")):
339
+ if "plugins" in path.parts:
340
+ continue
341
+ try:
342
+ st = path.stat()
343
+ rel = path.relative_to(ENGINES_DIR)
344
+ except (OSError, ValueError):
345
+ continue
346
+ count += 1
347
+ total += st.st_size
348
+ latest = max(latest, st.st_mtime)
349
+ digest.update(f"{rel}\0{st.st_size}\0{st.st_mtime_ns}\0{st.st_ctime_ns}\n".encode())
350
+ return latest, count, total, digest.hexdigest()
351
+
352
+
353
+ def _ttl_memo(cache: tuple[float, float] | None, ttl: float,
354
+ compute: Callable[[], float]) -> tuple[tuple[float, float] | None, float]:
355
+ """TTL 记忆化的唯一实现:TTL 内复用 cache,否则调 compute 并回填。
356
+
357
+ 同一个形状(`if ttl>0 and cache and now-cache[0]<ttl: return cache[1]`)
358
+ 此前在外置引擎 mtime 与 config_stamp 各写一遍,失效语义靠人工保持一致——
359
+ 一处改了 force 语义、另一处没改,就是这类漂移的温床(review 实测点出)。
360
+ 返回 (新 cache, 取值):ttl<=0 时不写记忆(关闭记忆化的逃生门)。
361
+ """
362
+ now = time.monotonic()
363
+ if ttl > 0 and cache is not None and now - cache[0] < ttl:
364
+ return cache, cache[1]
365
+ value = compute()
366
+ if ttl > 0:
367
+ return (now, value), value
368
+ return cache, value
369
+
370
+
371
+ def _remember_ext_scan(scan: tuple[float, int, int, str]) -> None:
372
+ """把刚测到的外置声明指纹回填记忆——force 重读后不必立刻再扫一遍。"""
373
+ global _ext_scan_cache
374
+ if _stamp_ttl() > 0:
375
+ _ext_scan_cache = (time.monotonic(), scan)
376
+
377
+
378
+ def _external_engines_scan() -> tuple[float, int, int, str]:
379
+ """外置声明目录指纹,按与 config_stamp 相同的 TTL 记忆。"""
380
+ global _ext_scan_cache
381
+ _ext_scan_cache, value = _ttl_memo(
382
+ _ext_scan_cache, _stamp_ttl(), _external_engines_scan_uncached)
383
+ return value
384
+
385
+
386
+ def _external_engines_mtime() -> float:
387
+ """外置引擎声明目录的最新 mtime,按与 config_stamp 相同的 TTL 记忆。
388
+
389
+ 扫一遍要 rglob 并 stat 全部外置声明(约 60 个文件、60 多次系统调用)。
390
+ load_config() 与 config_stamp() 在一次搜索里会被调用十几次,此前每次都
391
+ 重新扫盘,实测一次搜索因此产生约 900 次 stat,绝大多数是在重复读不会变
392
+ 的文件。外置声明新增/改动晚 1 秒生效没有实际影响,所以在 TTL 内只扫一次
393
+ (TTL 由 ARGO_CONFIG_STAMP_TTL_S 控制,设 0 即关闭记忆、每次即时扫描)。
394
+ """
395
+ return _external_engines_scan()[0]
396
+
397
+
398
+ _stamp_cache: tuple[float, float] | None = None # (monotonic 时刻, stamp)
399
+
400
+
401
+ def _stamp_ttl() -> float:
402
+ """stamp 记忆化的 TTL(秒)。每次读,便于测试与现场调参。
403
+
404
+ 走 engine_env.get_env 而非 os.environ 直读:开关写进 ~/.config/argo/env
405
+ (密钥与开关的规范位置)也要生效——这正是 env_flag 统一布尔计算方式时定下的
406
+ 契约,同一个旋钮不该有两套可读位置。engine_env 只依赖标准库,懒导入避免
407
+ 与 config 成环;导入或读取失败一律回落到 os.environ 与默认值。
408
+ """
409
+ name = "ARGO_CONFIG_STAMP_TTL_S"
410
+ raw = os.environ.get(name, "")
411
+ if not raw.strip():
412
+ try:
413
+ from engine_env import get_env
414
+ raw = get_env(name)
415
+ except Exception:
416
+ raw = ""
417
+ try:
418
+ return float(raw or 1.0)
419
+ except ValueError:
420
+ return 1.0
421
+
422
+
423
+ def config_stamp() -> float:
424
+ """config.yaml + 外置引擎声明的综合 mtime(供 registry 等热加载判断)。
425
+
426
+ 按 TTL 记忆化(默认 1 s,`ARGO_CONFIG_STAMP_TTL_S` 可调,设 0 即关闭):
427
+ 取值要 stat 全部 63 个外置声明文件,而 `engines.get_registry()` **每访问
428
+ 一次**注册表就会调它一次——实测一次搜索触发 28 次调用 = 1764 次 stat,
429
+ 99% 是重复劳动。热加载不要求亚秒级感知(配置改动晚 1 秒生效无实际影响),
430
+ 故折叠到 TTL 内一次。
431
+ """
432
+ global _stamp_cache
433
+ _stamp_cache, value = _ttl_memo(_stamp_cache, _stamp_ttl(),
434
+ _stamp_uncached)
435
+ return value
436
+
437
+
438
+ def _local_config_mtime() -> float:
439
+ """本机覆盖层 config.local.yaml 的 mtime(不存在 → 0.0)。
440
+
441
+ 与 config_stamp 的 TTL 记忆同一口径:覆盖层也是「会变的输入」,不进 stamp
442
+ 就会出现「改了本机覆盖层、1 秒内不生效」(load_config 的 _config_mtime 与
443
+ config_stamp 是两条独立的失效判据,都必须认它)。
444
+ """
445
+ try:
446
+ return LOCAL_CONFIG_PATH.stat().st_mtime
447
+ except OSError:
448
+ return 0.0
449
+
450
+
451
+ def _stamp_uncached() -> float:
452
+ try:
453
+ combined = CONFIG_PATH.stat().st_mtime
454
+ except OSError:
455
+ combined = 0.0
456
+ return max(combined, _external_engines_mtime(), _local_config_mtime())
457
+
458
+
459
+
460
+
461
+ def _load_config_public(force: bool = False) -> dict[str, Any]:
462
+ """加载**公共**配置(config.yaml + engines/*.yaml 外置声明),含全部缓存逻辑。
463
+
464
+ 刻意不含本机覆盖层:公共配置要能被「派生随公开仓发版的产物」复用
465
+ (sync_backends 生成 engine_registry.yaml / quota_profiles.json),本机状态
466
+ 不该进那两份派生件——否则本机跑一次 sync 就把本地覆盖层写进了公开仓。
467
+ 本机覆盖层由 load_config 按需并入(见那边的说明)。
468
+ """
469
+ global _config_cache, _config_mtime, _config_load_error, _stamp_cache
139
470
  try:
140
- mtime = CONFIG_PATH.stat().st_mtime
471
+ st = CONFIG_PATH.stat()
472
+ mtime = st.st_mtime
141
473
  except OSError:
142
474
  if _config_cache is None:
143
- _config_cache = _validate_engine_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG))))
475
+ base = _validate_engine_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG))))
476
+ _config_cache = _merge_external_engines(base)
144
477
  return _config_cache
145
478
 
146
- if not force and _config_cache is not None and mtime == _config_mtime:
479
+ # force=True 表示调用方明确要立即重读(如 --check、测试改写配置后),
480
+ # 此时绕过 TTL 记忆直接扫盘,保证强制刷新语义不被记忆化延迟。
481
+ scan = (_external_engines_scan_uncached() if force
482
+ else _external_engines_scan())
483
+ ext_mtime = scan[0]
484
+ combined_mtime = max(mtime, ext_mtime, _local_config_mtime())
485
+
486
+ if force:
487
+ # force 语义必须贯穿**所有**同级缓存,否则重读与快照脱节:外置声明
488
+ # 指纹记忆留着旧值 → 本函数下一次非 force 调用按旧值判定「没变」;
489
+ # stamp 记忆留着旧值 → 1 秒内 get_registry() 仍按旧 stamp 决定要不要
490
+ # 重建,于是「--check 之后立刻搜索」拿到的注册表与刚重读的配置不是
491
+ # 一个版本。两者都已在本函数里算出来了,直接回填,不额外扫盘。
492
+ _remember_ext_scan(scan)
493
+ if _stamp_ttl() > 0:
494
+ _stamp_cache = (time.monotonic(), combined_mtime)
495
+
496
+ if not force and _config_cache is not None and combined_mtime == _config_mtime:
147
497
  return _config_cache
148
498
 
499
+ # 跨进程缓存:force 不读(调用方明确要求重读,不许拿旧结论)。
500
+ # 整段包 try:缓存是加速器不是依赖,结构被外部改写之类的问题必须退化成
501
+ # 「走一遍完整解析」,绝不能把异常抛给每个调用方(那种表现是「CLI 每次都崩、
502
+ # 且看不出跟缓存有关」,用户无从知道要删哪个文件)。
503
+ if not force:
504
+ try:
505
+ # 内容摘要也在缓存判断之前算:它是唯一的强判据(见 _config_content_digest)
506
+ disk = _load_config_disk_cache(st, scan, _config_content_digest(st))
507
+ if disk is not None:
508
+ _config_cache = _validate_engine_paths(disk)
509
+ _config_mtime = combined_mtime
510
+ _config_load_error = None
511
+ return _config_cache
512
+ except Exception:
513
+ pass
514
+
149
515
  try:
150
- text = CONFIG_PATH.read_text(encoding="utf-8")
151
- parsed = _load_yaml(text)
516
+ # 与 peek_cache_db_path 共享同一次解析(同一份 123 KB 文本此前各解析
517
+ # 一遍,纯属重复劳动)
518
+ _ok, parsed, err = _read_config_yaml()
519
+ if err:
520
+ _config_load_error = err
152
521
  if parsed:
153
522
  expanded = _expand_value(parsed)
154
523
  resolved = _resolve_relative_paths(expanded)
524
+ resolved = _merge_external_engines(resolved)
525
+ # 外置 spec 的 cmd 是相对路径 → 合并后必须再解析一次
526
+ resolved = _resolve_relative_paths(resolved)
527
+ _save_config_disk_cache(st, scan, _config_content_digest(st), resolved)
155
528
  _config_cache = _validate_engine_paths(resolved)
156
- _config_mtime = mtime
157
- _config_load_error = None
529
+ _config_mtime = combined_mtime
530
+ _config_load_error = err
158
531
  elif _config_cache is None:
159
- _config_cache = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
532
+ base = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
533
+ _config_cache = _merge_external_engines(base)
160
534
  except ImportError as e:
161
535
  _config_load_error = str(e)
162
536
  import sys as _sys
163
537
  print(f"[unified-search] PyYAML 未安装,使用内置默认配置。安装:pip install pyyaml。原因:{e}", file=_sys.stderr)
164
538
  if _config_cache is None:
165
- _config_cache = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
539
+ base = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
540
+ _config_cache = _merge_external_engines(base)
166
541
  return _config_cache
167
542
  except Exception as e:
168
543
  _config_load_error = str(e)
169
544
  if _config_cache is None:
170
- _config_cache = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
545
+ base = _validate_engine_paths(_resolve_relative_paths(_expand_value(json.loads(json.dumps(DEFAULT_CONFIG)))))
546
+ _config_cache = _merge_external_engines(base)
171
547
  return _config_cache
172
548
 
173
549
 
550
+ def load_config(force: bool = False, *, include_local: bool = True) -> dict[str, Any]:
551
+ """加载配置;默认并入 config.local.yaml 本机覆盖层(见该文件与 _merge_local_config)。
552
+
553
+ include_local=False 用于派生**随公开仓发版**的产物(sync_backends 生成
554
+ engine_registry.yaml / quota_profiles.json):本机状态不该进那两份派生件。
555
+
556
+ 覆盖层在缓存**之后**并入:磁盘/进程缓存存的是公共配置(本机状态不进缓存
557
+ 键),覆盖层每次调用现并——它是小文件、深合并只碰重叠键,代价可忽略。这样
558
+ 「本机状态」与「可缓存的公共配置」两层彻底分开,缓存才能被派生件安全复用。
559
+ """
560
+ cfg = _load_config_public(force)
561
+ return _merge_local_config(cfg) if include_local else cfg
562
+
563
+
174
564
  def last_load_error() -> str | None:
175
565
  return _config_load_error
176
566
 
177
567
 
178
568
  # ── 类型化访问接口 ─────────────────────────────────────────────────────────────
179
569
 
180
- def get_engines(config: dict[str, Any] | None = None) -> dict[str, dict[str, Any]]:
181
- """返回启用的引擎配置字典。"""
570
+ # routable_only 过滤结果的进程内记忆化。背景(2026-09-30 route 回归修复):
571
+ # route_query 每 query 连调 get_engines(routable_only=True) 多次(主路径一次、
572
+ # 语言引擎回退可能再一次),每次对全部引擎逐个跑 env_ready/is_blocked——
573
+ # cProfile 实测 50 query 触发 ~12k 次 env_ready + ~12k 次 is_blocked,占 route
574
+ # 耗时约一半,基准捕获回归 6.5→8.8 ms/query。过滤结果的输入只有四类:
575
+ # config 内容、env 文件(密钥)、进程内 ENABLE/DISABLE 开关、admission 文件。
576
+ # 前三类已有现成签名/时刻可查,admission 读缓存默认 1s TTL——于是把 memo
577
+ # 时窗也定在 1s:进程内其他写路径改 admission(如熔断拉黑)最迟 1s 反映,
578
+ # 与底层读缓存同一口径,不引入新的陈旧度。
579
+ #
580
+ # 失效/关闭语义:
581
+ # - 键不匹配(config 改写 / env 文件变 / 开关变 / 跨时窗)→ 重算;
582
+ # - ARGO_ADMISSION_TTL_S=0(admission 缓存关闭)→ memo 一并关闭,
583
+ # 同一旋钮控制同一语义层的两处缓存,无侵入回滚;
584
+ # - 显式传 config 的调用方不走 memo——那类调用方(契约测试、自定义配置)
585
+ # - 明确要「这份 config 的精确过滤」,memo 键里没有它的位置。
586
+ # memo 值浅拷贝返回:route 只读,但防御调用方就地改写污染缓存。
587
+ _routable_memo: tuple[tuple, float, dict[str, dict[str, Any]]] | None = None
588
+
589
+
590
+ def _routable_memo_key(envfile_sig: tuple, stamp: float) -> tuple:
591
+ """memo 失效键:config 综合 mtime + env 文件签名 + 进程内引擎开关。"""
592
+ return (
593
+ stamp,
594
+ envfile_sig,
595
+ os.environ.get("ARGO_ENABLE_ENGINES", ""),
596
+ os.environ.get("ARGO_DISABLE_ENGINES", ""),
597
+ )
598
+
599
+
600
+ def get_engines(config: dict[str, Any] | None = None,
601
+ *,
602
+ routable_only: bool = False) -> dict[str, dict[str, Any]]:
603
+ """返回启用的引擎配置字典。
604
+
605
+ routable_only=True 时额外过滤:
606
+ - ARGO_ENABLE/DISABLE_ENGINES
607
+ - 缺 API Key
608
+ - admission blocked
609
+
610
+ 不带 config 调用时结果按 1s 时窗记忆化(键含 config_stamp / env 文件
611
+ 签名 / 进程内引擎开关,详见 _routable_memo 注释);显式传 config 则
612
+ 逐次精确过滤。ARGO_ADMISSION_TTL_S=0 时 memo 一并关闭。
613
+ """
182
614
  cfg = config if config is not None else load_config()
183
615
  engines = cfg.get("engines", {})
184
- return {name: spec for name, spec in engines.items() if spec.get("enabled", True)}
616
+ result = {name: spec for name, spec in engines.items() if spec.get("enabled", True)}
617
+ if not routable_only:
618
+ return result
619
+ try:
620
+ from engine_env import is_engine_allowed_by_env, env_ready, _envfile_sig # noqa: F401
621
+ from engine_admission import is_blocked # noqa: F401
622
+ ttl = _admission_ttl_mirror()
623
+ except ImportError as e:
624
+ # 单机可选依赖(engine_env / engine_admission)缺失时按历史语义返回
625
+ # 未过滤集,但**必须留痕**:静默返回意味着 blocked 与 env 未就绪的
626
+ # 引擎重新可路由,是最难查的一类故障。注意这里与上面两处不同——
627
+ # 上面两处是同一仓内的死回退(已删),这里是真会发生的降级(保留)。
628
+ # 守卫必须在一切过滤路径(含 _routable_filter)之前:拆函数时把守卫
629
+ # 留在原调用点,显式 config 路径就会裸抛 ModuleNotFoundError。
630
+ import logging
631
+ logging.getLogger("unified_search.config").warning(
632
+ "routable_only 过滤不可用(%s),本次返回未过滤引擎集", e)
633
+ return result
634
+ if config is not None:
635
+ return _routable_filter(result)
636
+ global _routable_memo
637
+ if ttl <= 0:
638
+ return _routable_filter(result) # 同一旋钮关 memo:回滚无侵入
639
+ now = time.monotonic()
640
+ key = _routable_memo_key(_envfile_sig, config_stamp())
641
+ if _routable_memo is not None:
642
+ m_key, m_at, m_val = _routable_memo
643
+ if m_key == key and now - m_at < 1.0:
644
+ return dict(m_val)
645
+ filtered = _routable_filter(result)
646
+ _routable_memo = (key, now, filtered)
647
+ return dict(filtered)
648
+
649
+
650
+ def _routable_filter(result: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
651
+ """逐引擎可路由过滤(env 白黑名单 → 密钥就绪 → 未被准入拉黑 → explicit_only)。
652
+
653
+ explicit_only 这道是 2026-10-19 补的契约收口:spec 声明「不进自动路由,
654
+ 只等显式 --engine」,engine_status 的 routable 判定一直排除它,两边注释
655
+ 写的同一口径;但本函数不过滤,route_query 的 enabled 候选池因此躺着全部
656
+ 18 个 explicit_only 引擎,TF-IDF 注入 / catch-all 注入 / backfill /
657
+ breaker_filter / tfidf_lead_combo / 通用兜底 sorted(enabled)[:2] 都能选中
658
+ 它们。此前没爆只因它们都不在 domain_profiles.json 语料里(数据兜底)——
659
+ 补一条语料词条,auto 档就会把唯一 cost_tier=paid 的 deepseek_search
660
+ 并行调起来扣费。声明是 spec 对路由器的承诺,在候选池的生产者一处兑现,
661
+ 好过在每个消费路径各判一次(漏一条就是一个新泄漏路径)。
662
+ 显式 --engine 路径不经 enabled,不受影响。
663
+ """
664
+ from engine_env import is_engine_allowed_by_env, env_ready
665
+ from engine_admission import is_blocked
666
+ filtered: dict[str, dict[str, Any]] = {}
667
+ for name, spec in result.items():
668
+ if not is_engine_allowed_by_env(name):
669
+ continue
670
+ if not env_ready(name, spec):
671
+ continue
672
+ if is_blocked(name):
673
+ continue
674
+ if spec.get("explicit_only"):
675
+ continue
676
+ filtered[name] = spec
677
+ return filtered
678
+
679
+
680
+ def _admission_ttl_mirror() -> float:
681
+ """读 admission 读缓存 TTL(ARGO_ADMISSION_TTL_S),import 失败回落 1.0。
682
+
683
+ config 不 import engine_env 顶层(依赖方向:engine 层引用 config,反过来
684
+ 会在 import 期成环),这里函数内窄 import;拿不到就按默认 1.0 走 memo。
685
+ """
686
+ try:
687
+ from engine_admission import _admission_ttl
688
+ return _admission_ttl()
689
+ except Exception:
690
+ return 1.0
185
691
 
186
692
 
187
693
  def get_domains(config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
@@ -190,6 +696,101 @@ def get_domains(config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
190
696
  return cfg.get("domains", [])
191
697
 
192
698
 
699
+ # 解析结果记忆化:key = config.yaml 的 (mtime_ns, size)。
700
+ # import 期的状态目录派发会连调 4 次(quota / adaptive / argo_engine_registry /
701
+ # cache 各自的模块级常量),此前每次重解析一遍 123 KB 配置——实测 4×~79 ms。
702
+ # 配置被改写(mtime 或 size 变)即失效,语义与逐次读盘一致。
703
+ #
704
+ # 这份解析结果是 peek 与 load_config **共用**的:两处此前各解析一遍同一份
705
+ # 123 KB 文本(C 版 loader 实测约 20 ms/遍),等于每个进程白等一遍。
706
+ _parsed_yaml_cache: tuple[tuple[int, int], dict[str, Any] | None] | None = None
707
+
708
+
709
+ def _read_config_yaml() -> tuple[bool, dict[str, Any] | None, str | None]:
710
+ """读并解析 config.yaml,按 (mtime_ns, size) 记忆化。
711
+
712
+ 返回 (可记忆, 解析结果, 错误信息):
713
+
714
+ - 可记忆=False 表示读取/解析链不可用(文件缺失、配置损坏),调用方不得
715
+ 写记忆——一次瞬时故障不该固化成本进程的永久 None(原 peek 契约)。
716
+ - 解析结果按原样缓存,**调用方只读**:load_config 先 _expand_value 出副本
717
+ 再归一化,所以缓存对象不会被就地改写。
718
+ - PyYAML 缺失仍抛 ImportError,由 load_config 给出安装提示(原先它对
719
+ ImportError 与 YAMLError 是两种处置)。
720
+ """
721
+ global _parsed_yaml_cache
722
+ try:
723
+ st = CONFIG_PATH.stat()
724
+ except OSError as e:
725
+ return False, None, f"config.yaml 不可读:{e}"
726
+ key = (st.st_mtime_ns, st.st_size)
727
+ if _parsed_yaml_cache is not None and _parsed_yaml_cache[0] == key:
728
+ return True, _parsed_yaml_cache[1], None
729
+ try:
730
+ text = CONFIG_PATH.read_text(encoding="utf-8")
731
+ except OSError as e:
732
+ return False, None, f"config.yaml 读取失败:{e}"
733
+ try:
734
+ parsed = _load_yaml(text)
735
+ except ImportError:
736
+ raise
737
+ except Exception as e:
738
+ # 含 yaml.YAMLError(ParserError 等)——它不是 ValueError 的子类。
739
+ # 此前只 catch (ImportError, ValueError) 会让「配置损坏」直接抛出,
740
+ # 与 fail-open 契约不符(2026-09-15 实测确认)。
741
+ return False, None, f"config.yaml 解析失败:{type(e).__name__}: {e}"
742
+ if not isinstance(parsed, dict):
743
+ parsed = None
744
+ _parsed_yaml_cache = (key, parsed)
745
+ return True, parsed, None
746
+
747
+
748
+ def peek_cache_db_path() -> str | None:
749
+ """轻量读取 config.yaml 的 cache.db_path,**不合并外置引擎 spec**。
750
+
751
+ 专用场景:import 期的路径派生(argo_paths._config_db_path → cache.py 的
752
+ DEFAULT_DB_PATH)。load_config() 会合并 engines/*.yaml 的全部外置声明,
753
+ 而这里只需要一个标量——为它付整轮合并不值得。
754
+
755
+ (勘误 2026-09-15:本文档与 65e6d2e 提交信息曾记「load_config 实测约
756
+ 1.7s」,该数字无法复现。同一台机器实测 load_config() 为纯 Python loader
757
+ 107 ms / C 版 15 ms(含 63 个 yaml、220 个引擎),因此当时的真实成本是
758
+ 「一次合并约 0.1 s,而 import 链上被连调 4 次」而非单次 1.7 s。数字已按
759
+ 可复现计算方式改写。)
760
+ 语义与 get_cache_config() 的 db_path 字段保持一致:用户未配置返回 None
761
+ (由调用方回退 state_path)。返回值统一是**展开后的形式**(`~` 已展开):
762
+ 缓存命中与走解析两条分支必须同一计算方式,调用方再 expanduser 一次也无副作用。
763
+ PyYAML 缺失 / 配置损坏时 fail-open 返回 None,与 _config_db_path 契约一致。
764
+
765
+ 结果按 config.yaml 的 (mtime_ns, size, ctime_ns) 记忆化:同一进程内多次
766
+ 调用只解析一次(与 load_config 共享同一份解析结果,见 _read_config_yaml)。
767
+ 解析链不可用时(PyYAML 缺失 / 配置损坏)不写记忆,下次调用仍会重试,避免
768
+ 把一次瞬时故障固化成本进程的永久 None。
769
+
770
+ 写入文件缓存命中时**连 YAML 都不解析**:db_path 只取决于 config.yaml 本身,
771
+ 而缓存键里已经记着这份文件的 stat,与逐次读盘语义一致。缓存未命中或停用
772
+ (ARGO_CONFIG_CACHE=0)则照旧走解析链。
773
+ """
774
+ try:
775
+ st = CONFIG_PATH.stat()
776
+ except OSError:
777
+ return None
778
+ hit, db_path = _peek_disk_cache_db_path(st, _config_content_digest(st))
779
+ if hit:
780
+ return db_path
781
+ try:
782
+ ok, parsed, _err = _read_config_yaml()
783
+ except ImportError:
784
+ return None
785
+ if not ok or not isinstance(parsed, dict):
786
+ return None
787
+ cache_cfg = parsed.get("cache")
788
+ if not isinstance(cache_cfg, dict):
789
+ return None
790
+ db_path = cache_cfg.get("db_path")
791
+ return os.path.expanduser(str(db_path)) if db_path else None
792
+
793
+
193
794
  def get_cache_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
194
795
  cfg = config if config is not None else load_config()
195
796
  return cfg.get("cache", DEFAULT_CONFIG["cache"])
@@ -206,9 +807,19 @@ def get_output_config(config: dict[str, Any] | None = None) -> dict[str, Any]:
206
807
 
207
808
 
208
809
  def get_cost_tiers(config: dict[str, Any] | None = None) -> dict[str, list[str]]:
209
- """返回成本分级配置。"""
810
+ """返回成本分级:从引擎声明的 cost_tier 字段聚合(唯一来源 config.yaml engines 段)。
811
+
812
+ 自 v2.6 起 cost_tiers 不再是独立配置段,新增引擎只需在 engines 段声明
813
+ cost_tier 字段,此处自动归入对应分级。
814
+ """
210
815
  cfg = config if config is not None else load_config()
211
- return cfg.get("cost_tiers", {})
816
+ tiers: dict[str, list[str]] = {}
817
+ for name, spec in cfg.get("engines", {}).items():
818
+ if not isinstance(spec, dict):
819
+ continue
820
+ tier = spec.get("cost_tier", "free")
821
+ tiers.setdefault(tier, []).append(name)
822
+ return tiers
212
823
 
213
824
 
214
825
  def get_budget_config(mode: str = "auto") -> dict[str, Any]:
@@ -218,16 +829,28 @@ def get_budget_config(mode: str = "auto") -> dict[str, Any]:
218
829
  return budgets.get(mode, budgets.get("auto", {}))
219
830
 
220
831
 
221
- def get_cost_factor(engine: str) -> float:
222
- """获取引擎的 cost_factor:free=1.0, low=0.7, paid=0.3。"""
832
+ def cost_tier_of(engine: str) -> str:
833
+ """引擎的成本档位:free / low / api / paid(未声明按 free 保底)。"""
223
834
  tiers = get_cost_tiers()
224
- if engine in tiers.get("free", []):
225
- return 1.0
226
- if engine in tiers.get("low", []):
227
- return 0.7
228
- if engine in tiers.get("paid", []):
229
- return 0.3
230
- return 1.0 # 未分级默认为 free
835
+ for tier in ("free", "low", "api", "paid"):
836
+ if engine in tiers.get(tier, []):
837
+ return tier
838
+ return "free"
839
+
840
+
841
+ # 成本因子唯一来源:越低越少被优先选中。
842
+ # 语义打分(tfidf_router)与 n 桶化判定(engines)都从这里取,不再各维护一套表
843
+ # ——此前 config 是 {free 1.0, low 0.7, paid 0.3}、tfidf_router 是
844
+ # {free 1.0, low 0.85, paid 0.6},同一个概念两个值。
845
+ # api 档(需密钥、按量计费,如 exa/octen/tavily/zhihu_global)保持 1.0:
846
+ # 额度消耗由配额表按 limit/period 跟踪并降权,这里不叠加惩罚;但**它们不参与
847
+ # n 桶化**(见 engines._free_engine)——按量计费的源把 n 从 5 放大到 10 是直接放大账单。
848
+ _COST_FACTOR_BY_TIER = {"free": 1.0, "low": 0.85, "api": 1.0, "paid": 0.6}
849
+
850
+
851
+ def get_cost_factor(engine: str) -> float:
852
+ """获取引擎的 cost_factor(按 cost_tier 查表,见上)。"""
853
+ return _COST_FACTOR_BY_TIER.get(cost_tier_of(engine), 1.0)
231
854
 
232
855
 
233
856
  # ── CLI 调试用 ─────────────────────────────────────────────────────────────────
@@ -242,21 +865,36 @@ def _cli():
242
865
  args = parser.parse_args()
243
866
  cfg = load_config(force=True)
244
867
  if args.engines:
245
- print(json.dumps(get_engines(cfg), ensure_ascii=False, indent=2))
868
+ print(dumps(get_engines(cfg)))
246
869
  elif args.domains:
247
- print(json.dumps(get_domains(cfg), ensure_ascii=False, indent=2))
870
+ print(dumps(get_domains(cfg)))
248
871
  elif args.cost_tiers:
249
- print(json.dumps(get_cost_tiers(cfg), ensure_ascii=False, indent=2))
872
+ print(dumps(get_cost_tiers(cfg)))
250
873
  elif args.check:
251
874
  err = last_load_error()
252
- print(json.dumps({
875
+ print(dumps({
253
876
  "path": str(CONFIG_PATH), "ok": err is None, "error": err,
254
877
  "engines": list(get_engines(cfg).keys()),
255
878
  "domains": [d.get("name") for d in get_domains(cfg)],
256
- }, ensure_ascii=False, indent=2))
879
+ }))
257
880
  else:
258
- print(json.dumps(cfg, ensure_ascii=False, indent=2))
881
+ print(dumps(cfg))
259
882
 
260
883
 
261
884
  if __name__ == "__main__":
262
885
  _cli()
886
+
887
+
888
+ def __getattr__(name: str) -> Any:
889
+ """惰性属性:`config.shutil` 不再常驻,但保持可被 patch.object 打桩。
890
+
891
+ PEP 562:模块 __getattr__ 只在属性缺失时触发。测试的
892
+ `patch.object(config_mod.shutil, "which", ...)` 解析 config_mod.shutil 时
893
+ 由此导入真实 shutil 模块并返回;patch 落在 shutil 模块对象的 which 属性上,
894
+ 函数级 `import shutil` 的用点(_validate_engine_paths)看到同一模块对象,
895
+ 打桩语义与模块级导入时完全一致。
896
+ """
897
+ if name == "shutil":
898
+ import shutil
899
+ return shutil
900
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")