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
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env python3
2
+ """daemon.py — argo 常驻进程:把每次 CLI 调用的固定成本变成本次 UDS 往返。
3
+
4
+ 背景(2026-10-11 实测,见仓库外分析底稿):一次 web 搜索 ~800ms 里,
5
+ dispatch(引擎网络等待)占 97%;真正的 Python 计算只有几十毫秒。但**每次
6
+ CLI 调用都要重付一遍「解释器启动 + 重模块 import」约 85–100ms**——engines
7
+ 全家(builders/urllib/http)26ms、search_entry 9.9ms、recovery 2.8ms……
8
+ agent 循环里一次研究任务调 15–20 次 argo,这笔固定成本就放大成 1.5–2s。
9
+
10
+ 对照 OpenAI Habitat(Python→Rust)案例的结论是:argo 的可省成本来自
11
+ 「每次调用的固定开销」,不是 Python 语言本身——所以正解是常驻进程把
12
+ import 付一次,而不是重写语言。收益相同,成本差两个数量级。
13
+
14
+ 设计:
15
+ - 协议:UDS 上逐连接一行 JSON 请求 / 一行 JSON 响应(stdlib socket,无新依赖)
16
+ - 执行:runpy.run_module 复刻 bin/argo 的原地执行语义(sys.argv 置换 +
17
+ stdout/stderr 重定向捕获),daemon 与 CLI 的行为严格同源
18
+ - 串行:请求执行持全局锁。原因:redirect_stdout 是进程级全局态,并发
19
+ 会串流;且 argo 内部本就高并发(并行效率 >1),CLI 层串行不损吞吐。
20
+ 连接接受与响应发送仍在多线程——排队不阻塞心跳/停止。
21
+ - 快路径:bin/argo 侧零 import 直连(socket 指针文件 `.daemon_sock-<树指纹>` 落在
22
+ bin/argo 可预测的缓存根下,解耦 argo_paths 的深层解析)
23
+ - 生命周期:`argo daemon start|status|stop|restart`;空闲 --idle-exit 秒
24
+ 自杀(默认 1800),不留僵尸;SIGTERM 同样清场
25
+ - 安全:socket chmod 0600(UDS 连接权限跟随文件权限,默认 umask 会放出
26
+ 0755 让同机其他用户连上);只放行白名单脚本
27
+
28
+ 第一笔 run 请求会多付 ~100ms(search 全家首次 import),此后全部热路径。
29
+ daemon 自身启动刻意轻:只 import argo_paths/engine_env,不预热引擎栈。
30
+
31
+ 运行:
32
+ python3 scripts/daemon.py start [--foreground] [--idle-exit 1800] [--json]
33
+ python3 scripts/daemon.py status [--json]
34
+ python3 scripts/daemon.py stop [--json]
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import contextlib
41
+ import errno
42
+ import glob
43
+ import io
44
+ import json
45
+ import os
46
+ import runpy
47
+ import signal
48
+ import socket
49
+ import sys
50
+ import threading
51
+ import time
52
+
53
+ SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__))
54
+ ARGO_HOME = os.path.dirname(SCRIPTS_DIR)
55
+ if SCRIPTS_DIR not in sys.path:
56
+ sys.path.insert(0, SCRIPTS_DIR)
57
+
58
+ from argo_paths import state_path, state_root # noqa: E402
59
+
60
+ SOCK_NAME = "daemon.sock"
61
+ PID_NAME = "daemon.pid"
62
+ LOG_NAME = "daemon.log"
63
+ POINTER_NAME = ".daemon_sock" # 指针文件:落 bin/argo 可预测的缓存根,内容=真实 socket 路径
64
+ MARKER_NAME = ".daemon_autostart" # 自启节流文件:落 _cache_root()
65
+ MAX_REQ_BYTES = 1 << 20 # 单请求上限 1MB(args 很小,此值纯防失控)
66
+ # 发送侧超时的兜底值(客户端未在请求里带 send_timeout 时的旧客户端路径)。
67
+ # 正常路径由客户端按自己的 ARGO_DAEMON_TIMEOUT+5 下达,见 handle()。
68
+ _SEND_TIMEOUT_S = 300.0
69
+
70
+ # 可执行脚本白名单(键=客户端传的 script 名,值=runpy 模块名)。
71
+ # 与 bin/argo.scripts 同源收窄:daemon 只收 CLI 映射表里真实存在的目标,
72
+ # 客户端 v1 只路由 search;表先备全,后续放行只需在此加行 + bin/argo 改路由。
73
+ RUNNABLE = {
74
+ "search": "search",
75
+ }
76
+
77
+
78
+ _fp_memo: tuple[float, str] | None = None
79
+ _FP_TTL_S = 1.0 # 与 config_stamp() 同口径:1s 内突发请求免扫 220 个文件
80
+
81
+
82
+ def _code_fingerprint(force: bool = False) -> str:
83
+ """仓库 Python 源码指纹:scripts/*.py + sub-skills 关键子技能 + bin/argo。
84
+
85
+ 为什么需要它:daemon 是长驻进程,但 `runpy.run_module` 只重执行脚本顶层,
86
+ 首次请求 import 进 sys.modules 的整棵依赖树(search_cli/search/search_pipeline/
87
+ engines*/sub-skills/local-search/search_v3 …)**不会被刷新**(2026-10-12
88
+ 审计实证:顶层体重执行、依赖仍是旧对象)。于是 `git pull` / 改动脚本后,
89
+ daemon 存活期内每次 search 都在静默跑旧代码,最长到空闲自杀(默认 30
90
+ 分钟);若新版脚本 import 了旧模块里不存在的符号,顶层 ImportError 还会
91
+ 变成「ok=True + exit 1」的硬失败。
92
+ 覆盖面必须包含 sub-skills:daemon 进程内经 engines.py 把 search_v3 固化
93
+ 进 sys.modules,只 hash scripts/ 会让「只改子技能」的变更漏出保护面。
94
+ 指纹不一致时客户端必须回退冷路径(见 _daemon_run),daemon 自身也退出,
95
+ 下一笔冷调用会按需重启新代码。
96
+ """
97
+ global _fp_memo
98
+ now = time.monotonic()
99
+ if not force and _fp_memo is not None and now - _fp_memo[0] < _FP_TTL_S:
100
+ return _fp_memo[1]
101
+ import hashlib
102
+ h = hashlib.sha1()
103
+ targets: list[str] = sorted(glob.glob(os.path.join(SCRIPTS_DIR, "*.py")))
104
+ base = os.path.dirname(SCRIPTS_DIR)
105
+ for rel in ("sub-skills/local-search", "sub-skills/local-seek",
106
+ "sub-skills/ego-search"):
107
+ targets.extend(sorted(glob.glob(os.path.join(base, rel, "**", "*.py"),
108
+ recursive=True)))
109
+ for p in targets:
110
+ try:
111
+ st = os.stat(p)
112
+ except OSError:
113
+ continue
114
+ h.update(f"{os.path.relpath(p, base)}:{st.st_mtime_ns}:{st.st_size}\n"
115
+ .encode("utf-8"))
116
+ # bin/argo 也在热路径上(客户端自己读),变更同样必须触发回退
117
+ try:
118
+ st = os.stat(os.path.join(ARGO_HOME, "bin", "argo"))
119
+ h.update(f"bin/argo:{st.st_mtime_ns}:{st.st_size}\n".encode("utf-8"))
120
+ except OSError:
121
+ pass
122
+ digest = h.hexdigest()
123
+ _fp_memo = (now, digest)
124
+ return digest
125
+
126
+
127
+
128
+ _TREE_TAG: str | None = None
129
+
130
+
131
+ def _tree_tag() -> str:
132
+ """本检出目录(ARGO_HOME realpath)的短指纹:daemon 落盘名字全部按树隔离。
133
+
134
+ 为什么需要(2026-10-16 实证):socket/pid/指针/自启标记/日志此前落在
135
+ 共享状态根(~/.cache/unified-search)的固定名上,同机多个检出目录
136
+ (git worktree、多宿主软链、他人另一份 clone)互相踩:
137
+ - B 树 daemon 抢到固定 socket 名后,A 树 CLI 经指针连上的是 B 的进程
138
+ ——跑的是 B 的代码与配置;而指纹检测在 B 进程内只比对 B 自己的源码
139
+ 树,A 的改动永远触发不了 stale,「改了不生效」且无法解释;
140
+ - pid 文件互相覆盖:A 树 `argo daemon stop` 杀的是 B 树的 daemon;
141
+ - 自启节流标记共享:A 树刚自启过,B 树 60s 内拉不起自己的 daemon。
142
+ 名字带 ARGO_HOME 指纹后每树一个 daemon,互不抢、互不误杀。
143
+ 兼容:旧固定名的在跑实例照常服务到空闲自杀(默认 30 分钟),新客户端按
144
+ 新名字自启本树实例,无迁移动作;stop/status 只作用于本树实例。
145
+ 两侧必须同算法同输入:bin/argo._tree_tag 与这里逐字一致(sha1(ARGO_HOME)
146
+ 前 12 hex),测试 test_daemon.py 锁这份契约。
147
+ """
148
+ global _TREE_TAG
149
+ if _TREE_TAG is None:
150
+ import hashlib
151
+ _TREE_TAG = hashlib.sha1(ARGO_HOME.encode("utf-8")).hexdigest()[:12]
152
+ return _TREE_TAG
153
+
154
+
155
+ def _cache_root() -> str:
156
+ """bin/argo 可预测的缓存根(与 bin/argo._default_cache_root 同一逻辑)。
157
+
158
+ 指针文件必须落在客户端**不 import 任何模块**就能算出的位置——entrypoint
159
+ 的零 import 纪律不可破,所以这里反向迁就它的简化解析(env → XDG → 历史
160
+ 目录),而不是客户端迁就 argo_paths 的完整解析(config.yaml db_path 分支
161
+ 客户端不读 yaml 就算不出来)。两边不一致时快路径静默失效退回常态执行,
162
+ 不会错。
163
+ """
164
+ # ARGO_STATE_DIR 与 bin/argo 的 _PY_CACHE 同一优先级:env 覆盖是测试隔离
165
+ # 的硬开关,指针文件必须跟 socket 一起落进被隔离的根,两端才对得上。
166
+ env_dir = os.environ.get("ARGO_STATE_DIR", "").strip()
167
+ if env_dir:
168
+ return env_dir
169
+ if os.name == "nt":
170
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
171
+ return os.path.join(base, "unified-search")
172
+ xdg = os.environ.get("XDG_CACHE_HOME", "").strip()
173
+ if xdg:
174
+ return os.path.join(xdg, "unified-search")
175
+ return os.path.expanduser("~/.cache/unified-search")
176
+
177
+
178
+ def _sock_path() -> str:
179
+ """socket 路径:默认与状态根同目录;超长时落短路径哈希位。
180
+
181
+ macOS/Linux 的 AF_UNIX 路径上限是 104 字节(sun_path),pytest 的深层
182
+ 临时目录、或用户把 ARGO_STATE_DIR 挪进深嵌套目录时,原生路径会超——
183
+ 实测 bind 直接 OSError「AF_UNIX path too long」。此时退到
184
+ /tmp/argo-daemon-<uid>/<state_root 哈希>.sock:目录 0700 保用户隔离,
185
+ 哈希保不同状态根互不冲突;可预测性由指针文件承担(客户端只读指针,
186
+ 不算 socket 路径),所以这里怎么挪都不影响两端契约。
187
+ """
188
+ native = str(state_path(SOCK_NAME + "-" + _tree_tag()))
189
+ if os.name == "nt" or len(native.encode("utf-8")) <= 100:
190
+ return native
191
+ import hashlib
192
+ h = hashlib.sha1(str(state_root()).encode("utf-8")).hexdigest()[:10]
193
+ d = f"/tmp/argo-daemon-{os.getuid()}"
194
+ return os.path.join(d, f"{h}-{_tree_tag()}.sock")
195
+
196
+
197
+ def _pointer_path() -> str:
198
+ return os.path.join(_cache_root(), POINTER_NAME + "-" + _tree_tag())
199
+
200
+
201
+ def _marker_path() -> str:
202
+ return os.path.join(_cache_root(), MARKER_NAME + "-" + _tree_tag())
203
+
204
+
205
+ def _pid_path() -> str:
206
+ return str(state_path(PID_NAME + "-" + _tree_tag()))
207
+
208
+
209
+ def _pid_is_daemon(pid: int) -> bool:
210
+ """核实 pid 的命令行确实是本 daemon(macOS/Linux ps,Windows 视为不是)。
211
+
212
+ 误杀的代价不对称:漏杀只是留一个下次 start 时 ping 不到、自己会空闲退出的
213
+ 进程;错杀是 SIGTERM 一个无关进程。所以判据从紧——单独出现 "daemon.py"
214
+ 子串不够(编辑器/别仓同名脚本都会中)。
215
+
216
+ 必须识别两种启动形态(2026-10-13 实测发现第二种此前漏判):
217
+ A. `python <本仓>/scripts/daemon.py start`——直接调用与 autostart 派生;
218
+ B. `./bin/argo daemon start`——bin/argo 经 runpy 原地执行时,Python 层的
219
+ sys.argv 被改写但**内核记录的 argv 不变**,ps 里始终是 bin/argo 形态。
220
+ 此前只认 A,B 形态下 stop 的 pid 兜底补刀会漏判,留孤儿进程。
221
+ """
222
+ if os.name == "nt":
223
+ return False
224
+ import subprocess
225
+ try:
226
+ r = subprocess.run(["ps", "-p", str(pid), "-o", "command="],
227
+ capture_output=True, text=True, timeout=5,
228
+ encoding="utf-8", errors="replace")
229
+ cmd = (r.stdout or "").strip()
230
+ except Exception:
231
+ return False
232
+ if not cmd:
233
+ return False
234
+ toks = cmd.split()
235
+ if "start" not in toks: # 两种形态都以 start 收尾;无 start 一律排除
236
+ return False
237
+ if any(t.endswith("daemon.py") for t in toks) and SCRIPTS_DIR in cmd:
238
+ return True # 形态 A
239
+ # 形态 B:bin/argo 入口(绝对/相对路径均可能,按 basename 认)
240
+ has_argo = any(os.path.basename(t.strip("'\"")) in ("argo", "argo.exe")
241
+ for t in toks)
242
+ return has_argo and "daemon" in toks
243
+
244
+
245
+ def _log_path() -> str:
246
+ return str(state_path(LOG_NAME + "-" + _tree_tag()))
247
+
248
+
249
+ def _atomic_write(path: str, text: str) -> None:
250
+ d = os.path.dirname(path)
251
+ if d:
252
+ os.makedirs(d, exist_ok=True)
253
+ tmp = f"{path}.tmp.{os.getpid()}"
254
+ with open(tmp, "w", encoding="utf-8") as f:
255
+ f.write(text)
256
+ os.replace(tmp, path)
257
+
258
+
259
+ # ── 状态查询 ────────────────────────────────────────────────────────────────
260
+
261
+
262
+ def ping(sock_path: str, timeout: float = 1.0) -> dict:
263
+ """对 socket 发 ping;活着返回响应 dict,死了抛 OSError。"""
264
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
265
+ s.settimeout(timeout)
266
+ try:
267
+ s.connect(sock_path)
268
+ s.sendall(b'{"op":"ping"}\n')
269
+ buf = b""
270
+ while not buf.endswith(b"\n"):
271
+ chunk = s.recv(65536)
272
+ if not chunk:
273
+ break
274
+ buf += chunk
275
+ return json.loads(buf.decode("utf-8", "replace"))
276
+ finally:
277
+ s.close()
278
+
279
+
280
+ def _alive() -> dict | None:
281
+ """daemon 存活则返回 ping 响应,否则 None。
282
+
283
+ 清场判据从紧(2026-10-12 审计修正):只在「连接被明确拒绝」时摘除
284
+ socket/指针——ping 超时(daemon 被 GIL 硬占/负载高)不代表它死了,
285
+ 此前任何异常都 unlink,会把活着 daemon 的入口摘掉,其监听 socket 成为
286
+ 永久不可达的孤儿,直到空闲自杀。宁可不摘(下次 start 会再探),不误摘。
287
+ """
288
+ sp = _sock_path()
289
+ if not os.path.exists(sp):
290
+ with contextlib.suppress(OSError):
291
+ os.unlink(_pointer_path())
292
+ return None
293
+ try:
294
+ return ping(sp)
295
+ except OSError as e:
296
+ if e.errno in (errno.ECONNREFUSED, errno.ENOENT):
297
+ for p in (sp, _pointer_path()):
298
+ with contextlib.suppress(OSError):
299
+ os.unlink(p)
300
+ return None
301
+ except Exception:
302
+ return None
303
+
304
+
305
+ # ── 请求执行 ────────────────────────────────────────────────────────────────
306
+
307
+ # 执行锁:stdout 重定向是进程级全局态,运行请求必须串行。持有者超时前
308
+ # 未释放时新请求不排队等待而是快速返回 busy——排队等待会让每个调用方的
309
+ # 客户端超时预算含上排队时间,冷路径本来就能并行跑,排队在这儿没有任何
310
+ # 收益(audit 2026-10-12)。
311
+ _EXEC_LOCK = threading.Lock()
312
+ _EXEC_WAIT_S = 0.2
313
+
314
+ # 环境变量透传白名单判据在客户端(bin/argo._daemon_env):daemon 侧只负责
315
+ # 应用/还原请求携带的覆盖。此处不再放第二份挑选规则——双份实现必然漂移,
316
+ # 且 daemon 侧那份此前全仓无人调用(2026-10-12 审查发现)。
317
+ _MAX_ENV_OVERRIDE_BYTES = 32768
318
+
319
+
320
+ def _apply_env_overrides(overrides) -> dict:
321
+ """请求级应用客户端环境;返回还原快照(键 → 旧值或 None)。"""
322
+ saved: dict = {}
323
+ if not isinstance(overrides, dict):
324
+ return saved
325
+ total = 0
326
+ for k, v in overrides.items():
327
+ if not isinstance(k, str) or not isinstance(v, str) or not k:
328
+ continue
329
+ total += len(k) + len(v)
330
+ if total > _MAX_ENV_OVERRIDE_BYTES:
331
+ return saved # 超限拒绝(防畸形请求灌爆环境);已应用的不回滚
332
+ saved[k] = os.environ.get(k)
333
+ os.environ[k] = v
334
+ return saved
335
+
336
+
337
+ def _restore_env(saved: dict) -> None:
338
+ for k, prev in saved.items():
339
+ if prev is None:
340
+ os.environ.pop(k, None)
341
+ else:
342
+ os.environ[k] = prev
343
+
344
+
345
+ def _run_request(req: dict) -> dict:
346
+ """执行一笔 run 请求:与 bin/argo 原地路径严格同语义。
347
+
348
+ run_module 而非直接调 main():与 bin/argo 的 runpy 路径一字不差,脚本
349
+ 的 `if __name__ == "__main__"` / argparse / sys.exit 语义原样生效。代价
350
+ 是每次重执行脚本顶层(__pycache__ 命中下 ~几 ms)——相对引擎 RTT 可忽略,
351
+ 换来的是零特判的「和 CLI 完全一样」。
352
+
353
+ 调用方必须已持有 _EXEC_LOCK(锁在 handle() 层做超时快失败)。
354
+ """
355
+ script = req.get("script")
356
+ module = RUNNABLE.get(script or "")
357
+ args = req.get("args") or []
358
+ if module is None:
359
+ return {"ok": False, "error": f"script 不在白名单: {script!r}"}
360
+ if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
361
+ return {"ok": False, "error": "args 必须是字符串列表"}
362
+
363
+ script_path = os.path.join(SCRIPTS_DIR, f"{script}.py")
364
+ t0 = time.perf_counter()
365
+ out, err = io.StringIO(), io.StringIO()
366
+ code = 0
367
+ old_argv, old_cwd = sys.argv, os.getcwd()
368
+ saved_env = _apply_env_overrides(req.get("env"))
369
+ try:
370
+ cwd = req.get("cwd")
371
+ if cwd and os.path.isdir(cwd):
372
+ os.chdir(cwd)
373
+ sys.argv = [script_path] + args
374
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
375
+ try:
376
+ runpy.run_module(module, run_name="__main__")
377
+ except SystemExit as e:
378
+ # 与 CLI 退出码契约一致:None/0 → 0,整数原样,其余 → 1
379
+ code = e.code if isinstance(e.code, int) else (0 if e.code is None else 1)
380
+ except BaseException as e: # 引擎栈漏出的非 SystemExit:按 CLI 语义退出码 1
381
+ code = 1
382
+ err.write(f"\n[daemon] {type(e).__name__}: {e}\n")
383
+ finally:
384
+ _restore_env(saved_env)
385
+ sys.argv = old_argv
386
+ with contextlib.suppress(OSError):
387
+ os.chdir(old_cwd)
388
+ return {
389
+ "ok": True,
390
+ "exit_code": code,
391
+ "stdout": out.getvalue(),
392
+ "stderr": err.getvalue(),
393
+ "took_ms": round((time.perf_counter() - t0) * 1000, 1),
394
+ }
395
+
396
+
397
+ # ── 服务循环 ────────────────────────────────────────────────────────────────
398
+
399
+
400
+ def _peer_closed(conn: socket.socket) -> bool:
401
+ """对端是否已断开(不消费数据,MSG_PEEK 探测)。
402
+
403
+ 用途:客户端超时/被杀后 socket 已关,但请求如果恰在排队或还没点火,
404
+ daemon 不应该再把它跑一遍——那是同一查询的付费调用与 usage 双计
405
+ (audit 2026-10-12)。已开始的执行不中断(引擎有各自超时且有缓存兜底),
406
+ 只挡住「还没开跑」这一类。
407
+ """
408
+ import select
409
+ try:
410
+ r, _, _ = select.select([conn], [], [], 0)
411
+ if not r:
412
+ return False
413
+ return conn.recv(1, socket.MSG_PEEK) == b""
414
+ except OSError:
415
+ return True
416
+
417
+
418
+ # stop/SIGTERM 时等在途 run 收尾的上限:宁可 stop 慢几秒,也不腰斩一笔已
419
+ # 开始的执行(腰斩会让客户端拿到 EOF 回退冷跑,同一查询双跑双计)。
420
+ _STOP_GRACE_S = 20.0
421
+
422
+
423
+ class _Server:
424
+ def __init__(self, idle_exit_s: float) -> None:
425
+ self.idle_exit_s = idle_exit_s
426
+ self.stop_evt = threading.Event()
427
+ self.active = 0
428
+ self.active_lock = threading.Lock()
429
+ self.last_activity = time.monotonic()
430
+ self.started_at = time.monotonic()
431
+ self.in_run = 0 # 正在执行的 run 数(0/1,受 _EXEC_LOCK 约束)
432
+ self.fp = "" # 启动时的源码指纹;请求时比对,变更即 stale
433
+
434
+ def handle(self, conn: socket.socket) -> None:
435
+ with self.active_lock:
436
+ self.active += 1
437
+ try:
438
+ conn.settimeout(60) # 兼容旧值 300s:哑连接不应把空闲退出推迟五分钟
439
+ buf = b""
440
+ while not buf.endswith(b"\n") and len(buf) < MAX_REQ_BYTES:
441
+ chunk = conn.recv(65536)
442
+ if not chunk:
443
+ break
444
+ buf += chunk
445
+ resp: dict = {"ok": False, "error": "空请求"}
446
+ # req 必须先绑定再使用:空请求 / 超 1MB / 解析失败 / 非 dict 体
447
+ # (如 JSON 数组)时 475 行的 `req.get("send_timeout")` 会
448
+ # NameError/AttributeError——两者都穿不透下面的 except OSError,
449
+ # handler 线程带 traceback 死掉,上面特意准备的错误响应永远没
450
+ # 发送,客户端拿 EOF 回退冷跑(2026-10-17 审查发现 2)。
451
+ req: dict = {}
452
+ if len(buf) > MAX_REQ_BYTES:
453
+ resp = {"ok": False, "error": "请求超长"}
454
+ buf = b""
455
+ if buf.strip():
456
+ try:
457
+ parsed = json.loads(buf.decode("utf-8", "replace"))
458
+ if isinstance(parsed, dict):
459
+ req = parsed
460
+ op = req.get("op")
461
+ if op == "ping":
462
+ now = time.monotonic()
463
+ resp = {"ok": True, "pid": os.getpid(),
464
+ "uptime_s": round(now - self.started_at, 1),
465
+ "idle_s": round(now - self.last_activity, 1),
466
+ "fp": self.fp}
467
+ elif op == "stop":
468
+ resp = {"ok": True, "stopping": True}
469
+ self.stop_evt.set()
470
+ elif op == "run":
471
+ resp = self._handle_run(conn, req)
472
+ else:
473
+ resp = {"ok": False, "error": f"未知 op: {op!r}"}
474
+ except Exception as e:
475
+ resp = {"ok": False, "error": f"{type(e).__name__}: {e}"}
476
+ # sendall 用的超时与 recv 的那把(60s)分开,且以**客户端声明
477
+ # 的预算**为准:一笔 run 超过 60s 时 sendall 抛 socket.timeout
478
+ # 会丢响应、逼客户端冷跑重来(同一查询双跑双计,2026-10-16 审查
479
+ # 发现)。请求里的 send_timeout 由客户端按自己的
480
+ # ARGO_DAEMON_TIMEOUT+5 填——服务端任何固定值都猜不到用户把
481
+ # 客户端等限调到了多大;缺失(旧客户端)时回退模块常量。
482
+ try:
483
+ _send_tmo = float(req.get("send_timeout"))
484
+ except (TypeError, ValueError):
485
+ _send_tmo = _SEND_TIMEOUT_S
486
+ conn.settimeout(_send_tmo if _send_tmo > 0 else None)
487
+ conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode("utf-8"))
488
+ except OSError:
489
+ pass # 客户端超时先走:响应丢弃即可,引擎侧照常完成
490
+ finally:
491
+ with contextlib.suppress(OSError):
492
+ conn.close()
493
+ with self.active_lock:
494
+ self.active -= 1
495
+
496
+ def _handle_run(self, conn: socket.socket, req: dict) -> dict:
497
+ """run 分支:忙时快失败;源码变更判 stale;对端已断不点火。"""
498
+ if not _EXEC_LOCK.acquire(timeout=_EXEC_WAIT_S):
499
+ return {"ok": False, "busy": True,
500
+ "error": "daemon 正忙(客户端应回退冷路径)"}
501
+ try:
502
+ if self.stop_evt.is_set():
503
+ # 已进入退役:不再点火(点火后进程退出会把它腰斩,客户端
504
+ # 拿不到响应只能冷跑重来——正是本次要消除的双跑)。
505
+ return {"ok": False, "error": "daemon 退役中(客户端应回退冷路径)"}
506
+ fp = _code_fingerprint()
507
+ if self.fp and fp and fp != self.fp:
508
+ self.stop_evt.set() # 旧代码 daemon 退役,下次冷调用按需起新
509
+ return {"ok": False, "stale": True,
510
+ "error": "仓库源码已变更:daemon 退出,客户端回退冷路径"}
511
+ if _peer_closed(conn):
512
+ return {"ok": False, "error": "客户端已断开,跳过执行"}
513
+ self.last_activity = time.monotonic()
514
+ self.in_run += 1
515
+ try:
516
+ resp = _run_request(req)
517
+ finally:
518
+ self.in_run -= 1
519
+ self.last_activity = time.monotonic()
520
+ return resp
521
+ finally:
522
+ _EXEC_LOCK.release()
523
+
524
+ def serve_forever(self) -> None:
525
+ sp = _sock_path()
526
+ # bind 前建目录:UDS bind 会在 socket 路径上落文件,目录不存在即
527
+ # FileNotFoundError(测试隔离/新机器首次启动必踩——上一版就栽在这)
528
+ d = os.path.dirname(sp)
529
+ if d:
530
+ os.makedirs(d, exist_ok=True)
531
+ if d.startswith("/tmp/argo-daemon-"):
532
+ os.chmod(d, 0o700) # 短路径位落在共享 /tmp:目录必须锁回本用户
533
+ listen_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
534
+ try:
535
+ listen_sock.bind(sp)
536
+ except OSError as e:
537
+ # 父进程只能看到「启动超时」,死因必须自己写进日志
538
+ print(f"[daemon] bind 失败 {sp}: {e}", flush=True)
539
+ raise
540
+ os.chmod(sp, 0o600) # UDS 连接权限=文件权限;默认 umask 会放出组/其他可连
541
+ listen_sock.listen(16)
542
+ self.fp = _code_fingerprint() # 请求期比对;源码一变即 stale(见 _handle_run)
543
+ _atomic_write(_pid_path(), f"{os.getpid()}\n")
544
+ _atomic_write(_pointer_path(), sp + "\n")
545
+
546
+ def _on_term(_sig, _frm) -> None:
547
+ self.stop_evt.set()
548
+
549
+ signal.signal(signal.SIGTERM, _on_term)
550
+ signal.signal(signal.SIGINT, _on_term)
551
+
552
+ def _watchdog() -> None:
553
+ while not self.stop_evt.wait(5.0):
554
+ if self.idle_exit_s > 0 and self.active == 0 \
555
+ and time.monotonic() - self.last_activity > self.idle_exit_s:
556
+ print(f"[daemon] 空闲 {self.idle_exit_s:.0f}s,自杀退出", flush=True)
557
+ self.stop_evt.set()
558
+
559
+ threading.Thread(target=_watchdog, daemon=True).start()
560
+
561
+ def _prewarm() -> None:
562
+ # 监听就绪后后台预热核心模块与域正则(与 mcp_handlers._warm_core_async 同构):
563
+ # 既不拖慢 start 握手(~30ms),又消除首笔 search 请求的 ~118ms 冷加载税。
564
+ if not _EXEC_LOCK.acquire(timeout=0.05):
565
+ return
566
+ try:
567
+ if self.stop_evt.is_set():
568
+ return
569
+ import search # noqa: F401
570
+ import search_cli # noqa: F401
571
+ from route_domains import warm_compiled_domains
572
+ warm_compiled_domains()
573
+ except Exception:
574
+ pass
575
+ finally:
576
+ _EXEC_LOCK.release()
577
+
578
+ threading.Thread(target=_prewarm, name="argo-daemon-warm", daemon=True).start()
579
+ print(f"[daemon] pid={os.getpid()} socket={sp} 就绪", flush=True)
580
+ try:
581
+ listen_sock.settimeout(2.0)
582
+ while not self.stop_evt.is_set():
583
+ try:
584
+ conn, _ = listen_sock.accept()
585
+ except socket.timeout:
586
+ continue
587
+ threading.Thread(target=self.handle, args=(conn,), daemon=True).start()
588
+ # 收尾第一步:立刻关闭并摘除监听 socket——退役期间新客户端不再
589
+ # 连进 backlog 干等(旧实现把 listen_sock 开到退出才关,
590
+ # stop 后紧跟的 search 要等整个收尾窗口才拿到 RST)。此后新连接
591
+ # 立即 ECONNREFUSED → 客户端静默回退冷路径。
592
+ with contextlib.suppress(OSError):
593
+ listen_sock.close()
594
+ for p in (_sock_path(), _pointer_path(), _marker_path()):
595
+ with contextlib.suppress(OSError):
596
+ os.unlink(p)
597
+ # 第二步:等在途 run 收尾(上限 _STOP_GRACE_S)——腰斩已开始的
598
+ # 执行会逼客户端回退冷跑、同查询双计。无在途执行时立即退出。
599
+ deadline = time.monotonic() + _STOP_GRACE_S
600
+ while time.monotonic() < deadline and self.in_run > 0:
601
+ time.sleep(0.05)
602
+ finally:
603
+ with contextlib.suppress(OSError):
604
+ os.unlink(_pid_path())
605
+ print("[daemon] 已退出", flush=True)
606
+
607
+
608
+ # ── 子命令 ──────────────────────────────────────────────────────────────────
609
+
610
+
611
+ def _quiet_stop(sp: str) -> bool:
612
+ """向在跑 daemon 发 stop 并等 socket 消失;成功 True。
613
+
614
+ 用于 start 的陈旧自愈:指纹不符的旧实例先请走,再起新代码的实例。
615
+ connect 失败不再直接判失败——旧实例可能恰在自行退役(accept 循环已退出、
616
+ socket 文件尚未清理),此时「连不上」= 已在停,视同成功(2026-10-12
617
+ 审查:假阴性会让陈旧自愈在最需要它的时刻拒绝启动)。
618
+ """
619
+ try:
620
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
621
+ s.settimeout(2.0)
622
+ s.connect(sp)
623
+ s.sendall(b'{"op":"stop"}\n')
624
+ s.close()
625
+ except OSError:
626
+ # 连不上:确认它确实不在响应(_alive 为 None)即视同已停
627
+ return _alive() is None
628
+ deadline = time.time() + 10
629
+ while time.time() < deadline:
630
+ if not os.path.exists(sp):
631
+ return True
632
+ time.sleep(0.1)
633
+ return False
634
+
635
+
636
+ def _start(args: argparse.Namespace) -> int:
637
+ alive = _alive()
638
+ if alive:
639
+ # 陈旧自愈:在跑的实例指纹与当前源码不符(git pull / 改脚本后)时,
640
+ # 老实例已进入「拒执行 + 准备退役」状态,但退役与重新拉起之间有时序
641
+ # 缝——客户端自启的新 start 会因「ping 得到旧进程」直接报已在运行并
642
+ # 退出,结果退役完成后无人接手(实测踩中:touch 脚本后 status 永为
643
+ # 未运行)。这里对指纹不符的实例先停、等它出场,再走正常启动。
644
+ cur_fp = _code_fingerprint(force=True)
645
+ running_fp = alive.get("fp") or ""
646
+ if running_fp and cur_fp and running_fp != cur_fp:
647
+ if not args.json:
648
+ print(f"argo daemon 检出旧代码实例(pid={alive.get('pid')}),重启",
649
+ file=sys.stderr)
650
+ if _quiet_stop(_sock_path()):
651
+ alive = None # 让流程继续到启动
652
+ else:
653
+ print(json.dumps({"ok": False, "error": "旧实例未能停止"},
654
+ ensure_ascii=False) if args.json
655
+ else "旧代码 daemon 未能停止;先 `argo daemon stop`", file=sys.stderr)
656
+ return 1
657
+ else:
658
+ print(json.dumps({"ok": True, "already_running": True, **alive},
659
+ ensure_ascii=False) if args.json
660
+ else f"argo daemon 已在运行 pid={alive.get('pid')}")
661
+ return 0
662
+
663
+ # 启动互斥(stub 文件 O_EXCL):两个并发 start 都通过 _alive 检查时会
664
+ # 双双 fork,输家 bind 失败、赢家被后到的父进程误报;更糟的是赢家在
665
+ # bind→listen 窗口内会被输家的 _alive 当陈尸。stub 让后来者等待赢家。
666
+ stub = _pid_path() + ".starting"
667
+ try:
668
+ os.makedirs(os.path.dirname(stub), exist_ok=True)
669
+ except OSError:
670
+ pass
671
+ acquired = False
672
+ for _attempt in range(3):
673
+ try:
674
+ fd = os.open(stub, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
675
+ os.close(fd)
676
+ acquired = True
677
+ break
678
+ except FileExistsError:
679
+ # 旧 stub 可能是上次崩溃残留(>20s 视为陈尸):清掉后立刻重抢
680
+ try:
681
+ if time.time() - os.stat(stub).st_mtime > 20:
682
+ os.unlink(stub)
683
+ continue
684
+ except OSError:
685
+ pass
686
+ # 活着的启动者:等它把 daemon 拉起来(或它中途放行 stub)
687
+ deadline = time.time() + 10
688
+ while time.time() < deadline:
689
+ info = _alive()
690
+ if info:
691
+ print(json.dumps({"ok": True, "already_running": True, **info},
692
+ ensure_ascii=False) if args.json
693
+ else f"argo daemon 已在运行 pid={info.get('pid')}")
694
+ return 0
695
+ if not os.path.exists(stub):
696
+ break # 对方放行:回到循环重抢 stub
697
+ time.sleep(0.1)
698
+ except OSError:
699
+ break # stub 建不了(只读状态目录等):退回无互斥的老路,不阻断启动
700
+ else:
701
+ # 三次都没抢到:对方仍在启动流程里,如实报告
702
+ print(json.dumps({"ok": False, "error": "另一进程正在启动 daemon,等待超时"},
703
+ ensure_ascii=False) if args.json
704
+ else "另一进程正在启动 argo daemon(等待超时)", file=sys.stderr)
705
+ return 1
706
+
707
+ try:
708
+ # socket 还在但 ping 不通:可能是在途请求把 GIL 占满的活 daemon。
709
+ # 不抢跑(bind 必失败、还可能把它的入口当陈尸摘掉),如实报告。
710
+ sp = _sock_path()
711
+ if os.path.exists(sp):
712
+ try:
713
+ ping(sp, 2.0)
714
+ except OSError as e:
715
+ if e.errno not in (errno.ECONNREFUSED, errno.ENOENT):
716
+ print(json.dumps({"ok": False, "error": "daemon socket 存在但无响应"},
717
+ ensure_ascii=False) if args.json
718
+ else "argo daemon socket 存在但无响应;先 `argo daemon stop`",
719
+ file=sys.stderr)
720
+ return 1
721
+ except Exception:
722
+ pass
723
+
724
+ if args.foreground or os.name == "nt": # Windows 无 fork:只支持前台
725
+ _serve_child(args)
726
+ return 0
727
+
728
+ pid = os.fork()
729
+ if pid > 0:
730
+ # 父进程:等 socket 就绪后报告(子进程 bind 完成即视为成功)
731
+ deadline = time.time() + 10
732
+ while time.time() < deadline:
733
+ try:
734
+ info = ping(_sock_path(), 0.5)
735
+ msg = {"ok": True, "pid": info.get("pid")}
736
+ print(json.dumps(msg, ensure_ascii=False) if args.json
737
+ else f"argo daemon 已启动 pid={info.get('pid')} socket={_sock_path()}")
738
+ return 0
739
+ except Exception:
740
+ time.sleep(0.05)
741
+ print(json.dumps({"ok": False, "error": "启动超时", "log": _log_path()},
742
+ ensure_ascii=False) if args.json
743
+ else f"argo daemon 启动超时,看日志:{_log_path()}", file=sys.stderr)
744
+ return 1
745
+ os.setsid()
746
+ # 重定向 stdio 到日志:daemon 不再依赖终端(日志目录同样可能还不存在)
747
+ log_dir = os.path.dirname(_log_path())
748
+ if log_dir:
749
+ os.makedirs(log_dir, exist_ok=True)
750
+ log = open(_log_path(), "ab", buffering=0)
751
+ os.dup2(log.fileno(), 0)
752
+ os.dup2(log.fileno(), 1)
753
+ os.dup2(log.fileno(), 2)
754
+ with contextlib.suppress(OSError):
755
+ log.close()
756
+ _serve_child(args)
757
+ return 0
758
+ finally:
759
+ with contextlib.suppress(OSError):
760
+ os.unlink(stub)
761
+
762
+
763
+ def _serve_child(args: argparse.Namespace) -> None:
764
+ try:
765
+ from engine_env import sync_envfile_to_environ
766
+ sync_envfile_to_environ()
767
+ except Exception:
768
+ pass # env 文件读不到不拦 daemon:引擎侧按各自缺 key 的既有口径失败
769
+ _Server(args.idle_exit).serve_forever()
770
+
771
+
772
+ def _stop(args: argparse.Namespace) -> int:
773
+ sp = _sock_path()
774
+ try:
775
+ ping(sp, 1.0)
776
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
777
+ s.settimeout(2.0)
778
+ s.connect(sp)
779
+ s.sendall(b'{"op":"stop"}\n')
780
+ buf = b""
781
+ while not buf.endswith(b"\n"):
782
+ chunk = s.recv(65536)
783
+ if not chunk:
784
+ break
785
+ buf += chunk
786
+ s.close()
787
+ # 等 socket 文件消失(退役第一步就会 unlink,正常毫秒级);socket 消
788
+ # 失后再给在途 run 一个收尾观察窗——文档口径是「上限 20s」,此前只
789
+ # 等 5s 就无条件报成功,在途收尾还没完时紧随的 start 会因 socket
790
+ # 仍在而报「先 stop」(用户被指回刚跑过的命令)。
791
+ deadline = time.time() + 5
792
+ while time.time() < deadline and os.path.exists(sp):
793
+ time.sleep(0.05)
794
+ pid = None
795
+ try:
796
+ with open(_pid_path(), encoding="utf-8") as f:
797
+ pid = int(f.read().strip())
798
+ except (OSError, ValueError):
799
+ pass
800
+ if pid:
801
+ grace = time.time() + _STOP_GRACE_S
802
+ while time.time() < grace:
803
+ try:
804
+ os.kill(pid, 0)
805
+ except OSError:
806
+ break # 进程已退出:真停
807
+ time.sleep(0.1)
808
+ with contextlib.suppress(OSError):
809
+ os.unlink(_marker_path())
810
+ print(json.dumps({"ok": True, "stopped": True, "pid": pid}, ensure_ascii=False)
811
+ if args.json else "argo daemon 已停止")
812
+ return 0
813
+ except Exception:
814
+ # socket 已死:靠 pid 文件兜底补刀,避免进程活着却没有入口的孤儿态。
815
+ # kill 前先核实命令行确实是本 daemon——pid 文件可能陈旧,而 OS 的 pid
816
+ # 会复用:裸 SIGTERM 一个恰好复用 pid 的无关进程是真实伤害。
817
+ killed = None
818
+ try:
819
+ with open(_pid_path(), encoding="utf-8") as f:
820
+ pid = int(f.read().strip())
821
+ if _pid_is_daemon(pid):
822
+ os.kill(pid, signal.SIGTERM)
823
+ killed = pid
824
+ except (OSError, ValueError):
825
+ pass
826
+ for p in (sp, _pointer_path(), _pid_path(), _marker_path()):
827
+ with contextlib.suppress(OSError):
828
+ os.unlink(p)
829
+ print(json.dumps({"ok": True, "stopped": True, "killed": killed},
830
+ ensure_ascii=False) if args.json else "argo daemon 已清理")
831
+ return 0
832
+
833
+
834
+ def _status(args: argparse.Namespace) -> int:
835
+ alive = _alive()
836
+ payload = {"ok": True, "running": bool(alive), "socket": _sock_path(),
837
+ "pid": (alive or {}).get("pid")}
838
+ if args.json:
839
+ print(json.dumps(payload, ensure_ascii=False))
840
+ else:
841
+ if alive:
842
+ print(f"argo daemon 运行中 pid={alive.get('pid')} socket={_sock_path()}")
843
+ else:
844
+ print(f"argo daemon 未运行(socket={_sock_path()})")
845
+ return 0
846
+
847
+
848
+ def _cli() -> int:
849
+ ap = argparse.ArgumentParser(description="argo 常驻 daemon(消 CLI 每次调用的启动+import 固定成本)")
850
+ sub = ap.add_subparsers(dest="cmd", required=True)
851
+ p_start = sub.add_parser("start", help="启动 daemon(默认后台化)")
852
+ p_start.add_argument("--foreground", action="store_true", help="前台运行(调试用)")
853
+ p_start.add_argument("--idle-exit", type=float, default=1800.0,
854
+ help="空闲 N 秒后自杀退出;0=常驻不退(默认 1800)")
855
+ for p in (p_start, sub.add_parser("status"), sub.add_parser("stop")):
856
+ p.add_argument("--json", action="store_true", help="机器可读输出")
857
+ args = ap.parse_args()
858
+ if args.cmd == "start":
859
+ return _start(args)
860
+ if args.cmd == "stop":
861
+ return _stop(args)
862
+ return _status(args)
863
+
864
+
865
+ if __name__ == "__main__":
866
+ raise SystemExit(_cli())