argo-search 1.0.1 → 2.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -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/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -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/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
package/scripts/quota.py CHANGED
@@ -17,128 +17,493 @@ import json
17
17
  import time
18
18
  import threading
19
19
  from pathlib import Path
20
- from typing import Optional
20
+ from typing import Any, Optional
21
+
22
+ import argo_paths
23
+ from cli_io import dumps
21
24
 
22
25
  # ── 路径 ──────────────────────────────────────────────────────────────────────
23
26
 
24
27
  SKILL_DIR = Path(__file__).parent.parent
25
28
  BACKENDS_DIR = SKILL_DIR / "backends"
26
29
  QUOTA_PROFILES_PATH = BACKENDS_DIR / "quota_profiles.json"
27
- QUOTA_STATE_DIR = Path.home() / ".cache" / "unified-search"
28
- QUOTA_STATE_PATH = QUOTA_STATE_DIR / "quota.json"
30
+
31
+
32
+ _STATE_DIR_CACHE: tuple[str, Path] | None = None
33
+
34
+
35
+ def _state_dir() -> Path:
36
+ """状态目录(惰性派生 + 按根记忆化,支持 ARGO_STATE_DIR 覆盖)。
37
+
38
+ 按 `argo_paths.state_root()` 的解析结果键控:测试在运行期切换
39
+ ARGO_STATE_DIR(conftest 全局隔离 + 若干用例的临时覆盖)时缓存自然失效,
40
+ 不会把上一个用例的目录当成事实。
41
+ """
42
+ global _STATE_DIR_CACHE
43
+ root = argo_paths.state_root()
44
+ key = str(root)
45
+ if _STATE_DIR_CACHE is None or _STATE_DIR_CACHE[0] != key:
46
+ _STATE_DIR_CACHE = (key, argo_paths.ensure_state_dir())
47
+ return _STATE_DIR_CACHE[1]
48
+
49
+
50
+ def _state_path() -> Path:
51
+ """quota.json 的完整路径(惰性解析,见下方 __getattr__ 的说明)。
52
+
53
+ 解析顺序:模块属性 `QUOTA_STATE_PATH`(测试与旧调用方直接赋值覆盖,
54
+ 这是它们既有的隔离契约)→ 惰性派生。PEP 562 的 `__getattr__` 只服务
55
+ 「属性不存在时」的外部读取,模块内的裸全局名查找看不见它,所以内部
56
+ 引用统一走本函数,赋值覆盖与惰性派生两条路都尊重。
57
+ """
58
+ override = globals().get("QUOTA_STATE_PATH")
59
+ if override is not None:
60
+ return override
61
+ return _state_dir() / "quota.json"
62
+
63
+
64
+ def __getattr__(name: str) -> Any:
65
+ # import 期零副作用(2026-09-27):这两个名字曾在模块级求值,而
66
+ # `ensure_state_dir()` 会 mkdir——于是每次 `import quota`(route →
67
+ # route_combo 全链路,含纯缓存命中的 CLI 调用)都在 import 阶段碰一次
68
+ # 文件系统。改成 PEP 562 惰性属性:旧引用 `quota.QUOTA_STATE_PATH`
69
+ # 的语义不变(首次使用时才派生),import 不再产生任何 I/O。
70
+ # 注意:外部**赋值** `quota.QUOTA_STATE_PATH = p` 会落进模块命名空间,
71
+ # 之后的读取与内部 _state_path() 都会尊重它(见该函数 docstring)。
72
+ if name == "QUOTA_STATE_DIR":
73
+ return _state_dir()
74
+ if name == "QUOTA_STATE_PATH":
75
+ return _state_path()
76
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
77
+
78
+
79
+ def _config_engine_names() -> set[str] | None:
80
+ """config.yaml 里声明的**全部**引擎名(含未启用的);取不到返回 None。
81
+
82
+ 刻意取全部而不是 `get_engines()`(它只返回 enabled=True 的):判据是
83
+ 「配置里有没有这个名字」,不是「现在能不能用」。按后者删会把「临时缺密钥 /
84
+ 熔断禁用」的源的历史计数一起抹掉,而它们恢复后要接着用这份账。
85
+ """
86
+ try:
87
+ from config import load_config
88
+ engines = (load_config() or {}).get("engines")
89
+ except Exception as e:
90
+ import sys
91
+ sys.stderr.write(
92
+ f"[quota] 配置加载失败({type(e).__name__}),本次跳过清理\n")
93
+ return None
94
+ return set(engines) if isinstance(engines, dict) else None
95
+
96
+
97
+ def _prune_foreign_engines(state: dict, protect: set[str]) -> dict:
98
+ """丢掉配置里根本不存在的引擎名;判据不可用时原样返回(fail-safe)。
99
+
100
+ why(2026-09-27 实测):本机 quota.json 419 条里 195 条是测试/探针残留
101
+ (`eng_x`、`benchmark_a`、`p_<8hex>`、`nope`…),而且**只增不减**——每跑一次
102
+ 测试或评测就往里加几个名字并永久留存。它不改变配额判断(那些名字不会被路由
103
+ 到),但让「引擎用量账本」这件事从根上不可信:账本里近一半的行不指向任何
104
+ 真实来源。
105
+
106
+ `protect` 是**本次正在写入**的名字,永不删:测试替身(`quick_primary`)与
107
+ 评测脚本(`benchmark_a`)都靠这个口子写非配置名,删掉它们等于让「写进了
108
+ 什么」与「读回来什么」不一致——实测三处并发/窗口用例因此变红。删的只是
109
+ 「本次没提到、配置里也不存在」的历史残留。
110
+
111
+ 位置选在唯一写入口 `record_many` 的更改函数里:判据一旦成立,下一次真实
112
+ 搜索就会顺带自愈,不需要额外的清理命令,也不会与并发写打架。
113
+ """
114
+ known = _config_engine_names()
115
+ if not known:
116
+ return state
117
+ return {name: st for name, st in state.items()
118
+ if name in known or name in protect}
29
119
 
30
120
 
31
121
  class QuotaManager:
32
122
  """配额追踪与消耗速率计算(v2)。"""
33
123
 
124
+ # 远端配额周期候选;本地 period=second/minute 只是限频计算方式,不代表远端
125
+ # 配额周期(火山免费额度按日),过短一律按 24h 保守处理
126
+ _PERIOD_SECONDS = {"hour": 3600, "day": 86400, "month": 30 * 86400}
127
+
34
128
  def __init__(self):
35
129
  self._lock = threading.Lock()
36
130
  self._profiles: dict = {}
37
131
  self._state: dict = {}
132
+ self._last_save_ts: float = 0.0 # 写盘节流时间戳
133
+ self._last_save_interval: float = 30.0 # 写盘节流间隔(秒)
38
134
  self._load_profiles()
39
135
  self._load_state()
136
+ # 热读监视器(跨进程):其他进程(CLI/另一客户端 server)改写
137
+ # profiles/state 后,本进程下一次带锁访问自动重读——配额自愈与
138
+ # 远端耗尽标记无需重启即全局可见。基线在初始加载后建立。
139
+ try:
140
+ from hot_state import HotFile
141
+ self._profiles_hot = HotFile(QUOTA_PROFILES_PATH)
142
+ self._state_hot = HotFile(_state_path())
143
+ # 基线与 init 加载的内存态保持一致(load 已读过磁盘):预建签名,
144
+ # 消除 HotFile「首次 changed 只建基线」把 init 之后、首次访问之前
145
+ # 的他进程写入吃掉的窗口
146
+ self._profiles_hot.reset()
147
+ self._state_hot.reset()
148
+ self._profiles_hot.changed()
149
+ self._state_hot.changed()
150
+ except Exception:
151
+ self._profiles_hot = None
152
+ self._state_hot = None
153
+
154
+ def _fresh_locked(self) -> None:
155
+ """带锁调用:磁盘文件签名变化即重读(调用方须已持锁)。"""
156
+ try:
157
+ if self._profiles_hot is not None and self._profiles_hot.changed():
158
+ self._load_profiles()
159
+ if self._state_hot is not None and self._state_hot.changed():
160
+ self._load_state()
161
+ except Exception as e:
162
+ import sys
163
+ sys.stderr.write(
164
+ f"[quota] 热文件检测失败({type(e).__name__})\n")
40
165
 
41
166
  def _load_profiles(self) -> None:
42
167
  if QUOTA_PROFILES_PATH.exists():
43
168
  try:
44
- self._profiles = json.loads(QUOTA_PROFILES_PATH.read_text())
169
+ self._profiles = json.loads(QUOTA_PROFILES_PATH.read_bytes())
45
170
  except (json.JSONDecodeError, OSError):
46
171
  self._profiles = {}
47
172
 
48
173
  def _load_state(self) -> None:
49
- if QUOTA_STATE_PATH.exists():
174
+ if _state_path().exists():
50
175
  try:
51
- self._state = json.loads(QUOTA_STATE_PATH.read_text())
176
+ self._state = json.loads(_state_path().read_bytes())
52
177
  except (json.JSONDecodeError, OSError):
53
- self._state = {}
54
-
55
- def _save_state(self) -> None:
56
- QUOTA_STATE_DIR.mkdir(parents=True, exist_ok=True)
57
- QUOTA_STATE_PATH.write_text(
58
- json.dumps(self._state, ensure_ascii=False, indent=2)
59
- )
178
+ # 损坏不清空:保留旧状态(配额/限频记忆),仅告警
179
+ import sys
180
+ print(f"[quota] 状态文件损坏,保留旧状态: {_state_path()}",
181
+ file=sys.stderr)
182
+
183
+ def _save_state(self, force: bool = False) -> None:
184
+ """原子写状态(临时文件名进程内唯一,见 argo_paths.atomic_write_json)。
185
+
186
+ ⚠️ 生产写入一律走 _mutate_locked(文件锁内「重读→改→写」)。本方法
187
+ 是**无锁读改写 + 30s 节流**:在锁序列里调用它,节流窗口内的变更会被
188
+ 后写者整体覆盖——复刻 017b01a 在 circuit_breaker 修掉的跨进程丢更新。
189
+ 当前仅测试引用(test_audit_fixes 直调、其余打桩跳写);接入任何生产
190
+ 路径前必须先包 file_lock 并去掉节流语义。
191
+
192
+ 旧实现用固定 `quota.json.tmp`:多进程(CLI 与 MCP server 并行、
193
+ 或评测脚本)同时写时互相搬走/删除对方的 tmp,replace 抛
194
+ FileNotFoundError,且失败方本次计数直接丢失。
195
+
196
+ 写盘节流(2026-09-27):identity memory 已有 30s 节流,quota 此前没有。
197
+ 一次 5 引擎搜索触发 5 次 record_many,每次都是全量序列化 + rename。
198
+ 加节流后,30s 内的多次写只落盘最后一次,磁盘 I/O 降 60-80%。
199
+ 状态丢失窗口 30s(进程崩溃时),可接受:配额计数是观测层,不是安全边界。
200
+ """
201
+ now = time.time()
202
+ if not force and (now - self._last_save_ts) < self._last_save_interval:
203
+ return
204
+ argo_paths.atomic_write_json(_state_path(), self._state)
205
+ self._last_save_ts = now
206
+
207
+ def _mutate_locked(self, mutator) -> None:
208
+ """跨进程安全的「重读 → 改 → 写入文件」序列。
209
+
210
+ 进程内 threading.Lock 只挡得住同进程线程;CLI / MCP server /
211
+ 评测脚本三者并行时,各自读到旧状态、各自 +1、后写者覆盖前写者,
212
+ 计数直接丢失。这里在文件锁内重读最新磁盘态,保证增量不丢。
213
+ """
214
+ with argo_paths.file_lock(_state_path()):
215
+ self._load_state()
216
+ mutator()
217
+ argo_paths.atomic_write_json(_state_path(), self._state)
60
218
 
61
219
  def record(self, engine: str, success: bool = True, credits: int = 1) -> None:
62
- """记录一次 API 调用。"""
220
+ """记录一次 API 调用(单条,跨进程安全)。"""
221
+ self.record_many([(engine, success)], credits=credits)
222
+
223
+ @staticmethod
224
+ def _prune_locked(st: dict, now: float) -> None:
225
+ """滑动窗口修剪:calls 与 errors 必须同窗口同步修剪。
226
+
227
+ 历史坑:只修剪 calls 却让 errors 永久累计,导致
228
+ ①错误率分子/分母不同窗口(实测可算出 300%);
229
+ ②真实状态里出现 errors > used 的反常(github used=1/errors=78)。
230
+ 窗口内没有调用 = 没有观测,此时 errors 也必须归零。
231
+ """
232
+ cutoff = now - 3600
233
+ kept = [t for t in st.get("calls", []) if t > cutoff]
234
+ st["calls"] = kept
235
+ # errors 没有逐条时刻,无法精确保持一致:上界取窗口内调用数,
236
+ # 窗口清空则归零。保证 errors 恒 ≤ 窗口 calls。
237
+ st["errors"] = min(st.get("errors", 0), len(kept))
238
+
239
+ def record_many(self, entries, *, credits: int = 1) -> None:
240
+ """批量记录(entries: (engine, success) 可迭代),只写入文件一次。
241
+
242
+ 批次搜索一次为每个引擎各写一次状态,而每次写都是「全量序列化 +
243
+ rename」。合并后写盘次数从 N 降到 1,且整批在同一个文件锁内完成。
244
+ """
245
+ entries = list(entries)
246
+ if not entries:
247
+ return
248
+ now = time.time()
249
+
250
+ def _apply() -> None:
251
+ # 自愈:丢掉「本次没提到、配置里也不存在」的历史残留
252
+ # (见 _prune_foreign_engines 的 why)。放在重读之后、逐条记账之前。
253
+ self._state = _prune_foreign_engines(
254
+ self._state, {name for name, _ok in entries})
255
+ for engine, success in entries:
256
+ st = self._state.setdefault(engine, {
257
+ "used": 0, "limit": 0, "calls": [],
258
+ "errors": 0, "last_reset": now, "total_cost": 0.0,
259
+ })
260
+ st["used"] = st.get("used", 0) + credits
261
+ st.setdefault("calls", []).append(now)
262
+ if not success:
263
+ st["errors"] = st.get("errors", 0) + 1
264
+ st["total_cost"] = st.get("total_cost", 0.0) + self.get_cost_per_call(engine)
265
+ self._prune_locked(st, now)
266
+
267
+ self._mutate_locked(_apply)
268
+
269
+ def _period_elapsed(self, engine: str, now: float) -> bool:
270
+ """该引擎的配额周期是否已过(只判断,不改状态)。"""
271
+ profile = self._profiles.get(engine, {})
272
+ state = self._state.get(engine)
273
+ if not isinstance(state, dict):
274
+ return False
275
+ span = 30 * 86400 if profile.get("period", "day") == "month" else 86400
276
+ return now - float(state.get("last_reset") or 0) > span
277
+
278
+ def _reset_period_locked(self, engine: str, now: float) -> None:
279
+ """周期重置的 load-modify-write,**调用方须已持文件锁**。
280
+
281
+ get_remaining_ratio / is_available 此前各抄了一份这段逻辑,也各抄了
282
+ 一份「threading.Lock 下改完就 _save_state」的写法——于是周期边界那一刻
283
+ 的并发写会互相覆盖整个状态文件。统一走这里:先判是否过期,过期才在
284
+ 锁内重读并归零。
285
+ """
286
+ with argo_paths.file_lock(_state_path()):
287
+ self._load_state()
288
+ if not self._period_elapsed(engine, now):
289
+ return
290
+ state = self._state.get(engine)
291
+ if not isinstance(state, dict):
292
+ return
293
+ state["used"] = 0
294
+ state["last_reset"] = now
295
+ argo_paths.atomic_write_json(_state_path(), self._state)
296
+
297
+ def get_remaining_ratio(self, engine: str) -> float:
298
+ """获取配额剩余比例。无限配额返回 1.0。"""
63
299
  with self._lock:
64
- if engine not in self._state:
65
- self._state[engine] = {
300
+ self._fresh_locked()
301
+ # 按周期重置(先于 limit 判空:null 引擎计数也要按周期归零,
302
+ # 否则用量计数永久累计、无周期语义)。重置在文件锁内落盘。
303
+ if self._period_elapsed(engine, time.time()):
304
+ self._reset_period_locked(engine, time.time())
305
+ profile = self._profiles.get(engine, {})
306
+ state = self._state.get(engine, {})
307
+ limit = profile.get("limit")
308
+ if limit is None:
309
+ return 1.0
310
+ return max(0.0, (limit - state.get("used", 0)) / limit)
311
+
312
+ @staticmethod
313
+ def _engine_key_fp(engine: str) -> str:
314
+ """当前引擎生效密钥的短指纹(8 hex);无密钥或读取失败返回空串。"""
315
+ try:
316
+ import hashlib
317
+ from engine_env import get_env, resolve_env_name
318
+ val = get_env(resolve_env_name(engine))
319
+ return hashlib.sha256(val.encode("utf-8")).hexdigest()[:8] if val else ""
320
+ except Exception:
321
+ return ""
322
+
323
+ @classmethod
324
+ def _mark_expired(cls, engine: str, mark: Any, now: float) -> bool:
325
+ """远端耗尽标记是否已失效(周期到期、数据损坏、或已更换新 API Key)。"""
326
+ if not isinstance(mark, dict) or now >= float(mark.get("until") or 0):
327
+ return True
328
+ saved_fp = mark.get("key_fp")
329
+ if saved_fp:
330
+ cur_fp = cls._engine_key_fp(engine)
331
+ if cur_fp != saved_fp:
332
+ return True
333
+ return False
334
+
335
+ def mark_remote_exhausted(self, engine: str, reason: str = "",
336
+ period: str | None = None) -> None:
337
+ """远端明示「配额耗尽」(如火山 10406 Free quota exhausted)时调用。
338
+
339
+ 设计目标:配额问题不需要人工改配置——标记后路由组合层全模式排除
340
+ 该引擎,备用源自然接管;到下一周期边界或更换新 Key 时惰性自愈(is_available /
341
+ is_hard_down 检查时清除),恢复后引擎自动回归。提前恢复(如原 Key 充值)
342
+ 可执行 `python3 scripts/quota.py reset <engine>`。
343
+ """
344
+ key_fp = self._engine_key_fp(engine)
345
+ with self._lock:
346
+ def _apply() -> None:
347
+ st = self._state.setdefault(engine, {
66
348
  "used": 0, "limit": 0, "calls": [],
67
349
  "errors": 0, "last_reset": time.time(), "total_cost": 0.0,
350
+ })
351
+ profile = self._profiles.get(engine, {})
352
+ p = period or profile.get("period") or "day"
353
+ seconds = self._PERIOD_SECONDS.get(p, 86400)
354
+ if seconds < 3600:
355
+ seconds = 86400
356
+ st["remote_exhausted"] = {
357
+ "until": time.time() + seconds,
358
+ "reason": (reason or "")[:200],
359
+ "marked_at": time.time(),
360
+ "key_fp": key_fp,
68
361
  }
69
- self._state[engine]["used"] += credits
70
- self._state[engine]["calls"].append(time.time())
71
- if not success:
72
- self._state[engine]["errors"] += 1
73
- # 累加成本
74
- cost = self.get_cost_per_call(engine)
75
- self._state[engine]["total_cost"] = self._state[engine].get("total_cost", 0.0) + cost
76
-
77
- # 只保留最近 1 小时的时间戳
78
- cutoff = time.time() - 3600
79
- self._state[engine]["calls"] = [
80
- t for t in self._state[engine]["calls"] if t > cutoff
81
- ]
82
- self._save_state()
362
+ self._mutate_locked(_apply)
83
363
 
84
- def get_remaining_ratio(self, engine: str) -> float:
85
- """获取配额剩余比例。无限配额返回 1.0。"""
86
- profile = self._profiles.get(engine, {})
87
- state = self._state.get(engine, {})
88
- limit = profile.get("limit")
89
- if limit is None:
90
- return 1.0
91
- used = state.get("used", 0)
92
- period = profile.get("period", "day")
93
- last_reset = state.get("last_reset", 0)
94
- now = time.time()
364
+ def clear_remote_exhausted(self, engine: str) -> bool:
365
+ """手动清除远端耗尽标记(充值后提前恢复)。"""
366
+ cleared = False
367
+ with self._lock:
368
+ def _apply() -> None:
369
+ nonlocal cleared
370
+ st = self._state.get(engine)
371
+ if st and "remote_exhausted" in st:
372
+ st.pop("remote_exhausted", None)
373
+ cleared = True
374
+ # 锁内重读:不清也可能命中(他进程已清),故只在真删掉时写盘
375
+ with argo_paths.file_lock(_state_path()):
376
+ self._load_state()
377
+ _apply()
378
+ if cleared:
379
+ argo_paths.atomic_write_json(_state_path(), self._state)
380
+ return cleared
381
+
382
+ def _refresh_remote_state_locked(self, engine: str, now: float) -> None:
383
+ """周期边界或换 Key 自愈(调用方须已持锁)。
384
+
385
+ 走 _mutate_locked 而非就地 _save_state:这是 load-modify-write,
386
+ 只用 threading.Lock 时 CLI 与 MCP 并发会互相覆盖整个状态文件。
387
+ """
388
+ st = self._state.get(engine) or {}
389
+ mark = st.get("remote_exhausted")
390
+ if mark is not None and self._mark_expired(engine, mark, now):
391
+ def _drop() -> None:
392
+ (self._state.get(engine) or {}).pop("remote_exhausted", None)
393
+ self._mutate_locked(_drop)
394
+
395
+ def is_remote_exhausted(self, engine: str) -> bool:
396
+ with self._lock:
397
+ self._fresh_locked()
398
+ self._refresh_remote_state_locked(engine, time.time())
399
+ return "remote_exhausted" in (self._state.get(engine) or {})
95
400
 
96
- # 按周期重置
97
- if period == "month" and now - last_reset > 30 * 86400:
98
- state["used"] = 0
99
- state["last_reset"] = now
100
- self._save_state()
101
- used = 0
102
- elif period == "day" and now - last_reset > 86400:
103
- state["used"] = 0
104
- state["last_reset"] = now
105
- self._save_state()
106
- used = 0
107
- return max(0.0, (limit - used) / limit)
401
+ def remote_exhausted_marks(self) -> dict[str, dict[str, Any]]:
402
+ """处于「远端配额耗尽」状态的引擎 → {reason, until}。
403
+
404
+ 一次性快照,供 `--list-engines` 展示:逐引擎调 is_remote_exhausted 会
405
+ 重复走热读检查。顺带做过期/换 Key 自愈(与 _refresh_remote_state_locked 同计算方式),
406
+ 坏标记(非 dict)按过期处理。
407
+ """
408
+ with self._lock:
409
+ self._fresh_locked()
410
+ now = time.time()
411
+ out: dict[str, dict[str, Any]] = {}
412
+ stale: list[str] = []
413
+ for engine, st in list(self._state.items()):
414
+ if not isinstance(st, dict) or "remote_exhausted" not in st:
415
+ continue
416
+ mark = st.get("remote_exhausted")
417
+ if self._mark_expired(engine, mark, now):
418
+ stale.append(engine)
419
+ continue
420
+ out[engine] = {
421
+ "reason": str(mark.get("reason") or ""),
422
+ "until": float(mark.get("until") or 0),
423
+ }
424
+ # 过期自愈是 load-modify-write:与其他写路径一样必须在文件锁内,
425
+ # 否则与并发的 record/mark 互相覆盖(丢计数或丢标记)。
426
+ if stale:
427
+ def _drop() -> None:
428
+ for eng in stale:
429
+ (self._state.get(eng) or {}).pop("remote_exhausted", None)
430
+ self._mutate_locked(_drop)
431
+ return out
432
+
433
+ def is_hard_down(self, engine: str) -> bool:
434
+ """配额意义上不可用:远端耗尽或本地剩余为 0。
435
+
436
+ 与限频/预算无关——路由组合层用它做全模式排除;
437
+ is_available 的限频/付费判断不在此列。
438
+ """
439
+ if self.is_remote_exhausted(engine):
440
+ return True
441
+ return self.get_remaining_ratio(engine) <= 0
108
442
 
109
443
  def get_current_rpm(self, engine: str) -> float:
110
444
  """获取最近 1 分钟的调用速率。"""
111
- state = self._state.get(engine, {})
112
- now = time.time()
113
- return len([t for t in state.get("calls", []) if now - t < 60])
445
+ with self._lock:
446
+ state = self._state.get(engine, {})
447
+ now = time.time()
448
+ return len([t for t in state.get("calls", []) if now - t < 60])
114
449
 
115
450
  def get_error_rate(self, engine: str) -> float:
116
- """获取最近 1 小时的错误率。"""
117
- state = self._state.get(engine, {})
118
- total = len(state.get("calls", []))
119
- if total == 0:
120
- return 0.0
121
- return state.get("errors", 0) / total
451
+ """最近 1 小时的错误率。
452
+
453
+ 旧实现分子分母不同窗口:errors 是**累计**值(从不衰减),calls
454
+ 只保留最近 1 小时。两次失败后哪怕窗口内全是成功调用,算出来也会
455
+ >1(实测 1 次成功 + 历史 3 次错 → 3.0 = 300%)。
456
+
457
+ 现在改为同窗口:窗口内没有样本时返回 0.0(无观测 ≠ 高错误率)。
458
+ """
459
+ with self._lock:
460
+ state = self._state.get(engine, {})
461
+ cutoff = time.time() - 3600
462
+ window_calls = [t for t in state.get("calls", []) if t > cutoff]
463
+ if not window_calls:
464
+ return 0.0
465
+ return min(1.0, state.get("errors", 0) / len(window_calls))
122
466
 
123
467
  def is_available(self, engine: str, mode: str = "auto") -> bool:
124
468
  """检查引擎是否可用(配额未耗尽且未触发限频 + 预算模式)。"""
125
- qr = self.get_remaining_ratio(engine)
126
- if qr <= 0:
469
+ with self._lock:
470
+ return self._is_available_locked(engine, mode)
471
+
472
+ def _is_available_locked(self, engine: str, mode: str = "auto") -> bool:
473
+ """is_available 的持锁内部版本(调用方必须已持有 self._lock)。
474
+
475
+ 将 is_remote_exhausted / get_remaining_ratio / get_current_rpm 的
476
+ 检查合并到单次持锁中,消除 TOCTOU 间隙。
477
+ """
478
+ self._fresh_locked()
479
+ # is_remote_exhausted 的内联版本(不重复获取锁)
480
+ self._refresh_remote_state_locked(engine, time.time())
481
+ if "remote_exhausted" in (self._state.get(engine) or {}):
127
482
  return False
128
-
483
+ # get_remaining_ratio 的内联版本(不重复获取锁)
129
484
  profile = self._profiles.get(engine, {})
485
+ state = self._state.get(engine, {})
486
+ now = time.time()
487
+ if self._period_elapsed(engine, now):
488
+ self._reset_period_locked(engine, now)
489
+ state = self._state.get(engine, {})
490
+ used = state.get("used", 0)
491
+ limit = profile.get("limit")
492
+ if limit is not None:
493
+ qr = max(0.0, (limit - used) / limit)
494
+ if qr <= 0:
495
+ return False
496
+ # get_current_rpm 的内联版本(不重复获取锁)
130
497
  qps = profile.get("qps")
131
498
  if qps is not None:
132
- rpm = self.get_current_rpm(engine)
499
+ rpm = len([t for t in state.get("calls", []) if now - t < 60])
133
500
  if rpm >= qps * 60:
134
501
  return False
135
-
136
- # budget 模式禁用付费引擎
137
- if mode in ("fast", "budget"):
138
- cost_tier = profile.get("cost_tier", "free")
139
- if cost_tier == "paid":
140
- return False
141
-
502
+ # fast/budget 禁用按量计费引擎。判据必须连 api 档一起拦——7 个按量源
503
+ # (exa/octen/parallel/seltz/tavily/you/zhihu_global)声明的都是 api 档,
504
+ # 只认 paid 会让「免费优先 / 配额控制」两个模式照样选中它们并烧额度。
505
+ if mode in ("fast", "budget") and profile.get("cost_tier") in ("api", "paid"):
506
+ return False
142
507
  return True
143
508
 
144
509
  def get_cost_per_call(self, engine: str) -> float:
@@ -150,7 +515,8 @@ class QuotaManager:
150
515
 
151
516
  def get_total_cost(self, engine: str) -> float:
152
517
  """获取引擎累计成本。"""
153
- return self._state.get(engine, {}).get("total_cost", 0.0)
518
+ with self._lock:
519
+ return self._state.get(engine, {}).get("total_cost", 0.0)
154
520
 
155
521
  def get_stats(self) -> dict:
156
522
  """获取所有引擎的配额统计。"""
@@ -159,6 +525,10 @@ class QuotaManager:
159
525
  if engine.startswith("_") or not isinstance(self._profiles[engine], dict):
160
526
  continue
161
527
  profile = self._profiles[engine]
528
+ # 残缺状态防御:state JSON 手工编辑/截断时 mark 可能非 dict,
529
+ # 与 _refresh_remote_state_locked 的 .get 计算方式保持一致
530
+ raw_mark = (self._state.get(engine) or {}).get("remote_exhausted")
531
+ mark = raw_mark if isinstance(raw_mark, dict) else None
162
532
  stats[engine] = {
163
533
  "remaining_ratio": round(self.get_remaining_ratio(engine), 2),
164
534
  "rpm": self.get_current_rpm(engine),
@@ -169,6 +539,8 @@ class QuotaManager:
169
539
  "used": self._state.get(engine, {}).get("used", 0),
170
540
  "limit": profile.get("limit", "∞"),
171
541
  "cost_tier": profile.get("cost_tier", "free"),
542
+ "remote_exhausted_until": (
543
+ round(float(mark["until"])) if mark else None),
172
544
  }
173
545
  return stats
174
546
 
@@ -191,6 +563,46 @@ if __name__ == "__main__":
191
563
  import sys
192
564
  mgr = get_quota_manager()
193
565
  if len(sys.argv) > 1 and sys.argv[1] == "stats":
194
- print(json.dumps(mgr.get_stats(), ensure_ascii=False, indent=2))
566
+ print(dumps(mgr.get_stats()))
567
+ elif len(sys.argv) > 2 and sys.argv[1] == "reset":
568
+ # 充值后提前恢复:清除远端配额耗尽标记
569
+ ok = mgr.clear_remote_exhausted(sys.argv[2])
570
+ print(f"{'✅ 已清除' if ok else 'ℹ️ 无标记'}: {sys.argv[2]}")
195
571
  else:
196
- print("用法: python3 quota.py stats")
572
+ print("用法: python3 quota.py stats | python3 quota.py reset <engine>")
573
+
574
+
575
+ class _QuotaBatch:
576
+ """一次搜索的配额记账收集器(累积 → 一次性写入文件)。
577
+
578
+ 为什么不是每引擎各写一次:每次 record 都是「全量状态序列化 + rename」,
579
+ 一次 5 引擎搜索即 5 次全量写。合并后写盘次数从 N 降到 1,且整批在
580
+ 同一个跨进程文件锁内完成(`QuotaManager.record_many`)。
581
+
582
+ 失败静默:记账属于观测层,任何异常都不得拖累搜索主路径。
583
+ """
584
+
585
+ def __init__(self) -> None:
586
+ self._entries: list[tuple[str, bool]] = []
587
+
588
+ def add(self, engine: str, success: bool) -> None:
589
+ self._entries.append((engine, success))
590
+
591
+ def flush(self) -> None:
592
+ # 取出但不立即清空:写盘失败时把这批放回队首,下次 flush 会重试。
593
+ # 原实现 `entries, self._entries = self._entries, []` 先清空再写,
594
+ # 于是 record_many 一旦抛异常(磁盘满 / 状态文件被换 inode),这批
595
+ # 记账**永久消失且无任何痕迹**——配额被系统性少记,用户只会看到
596
+ # 「额度仿佛变多了」,而那正是最需要报警的信号。
597
+ # 代价:失败时本批滞留缓冲,可能被重复计入;但重复计入的方向是
598
+ # 「高估已用量」(引擎被限得更狠),远好过「低估已用量」(超用)。
599
+ if not self._entries:
600
+ return
601
+ entries = list(self._entries)
602
+ try:
603
+ from quota import get_quota_manager
604
+ get_quota_manager().record_many(entries)
605
+ except Exception:
606
+ return
607
+ # 只在成功后丢弃已写入的前缀,保留 flush 期间新 add 的条目
608
+ del self._entries[:len(entries)]