calvyn-code 0.14.0

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 (1718) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/README.zh-CN.md +180 -0
  4. package/acp_adapter/__init__.py +1 -0
  5. package/acp_adapter/__main__.py +5 -0
  6. package/acp_adapter/auth.py +68 -0
  7. package/acp_adapter/bootstrap/__init__.py +0 -0
  8. package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
  9. package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
  10. package/acp_adapter/entry.py +292 -0
  11. package/acp_adapter/events.py +265 -0
  12. package/acp_adapter/permissions.py +148 -0
  13. package/acp_adapter/server.py +1713 -0
  14. package/acp_adapter/session.py +629 -0
  15. package/acp_adapter/tools.py +1180 -0
  16. package/agent/__init__.py +6 -0
  17. package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
  19. package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
  20. package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
  21. package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
  22. package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
  23. package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
  24. package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
  25. package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
  26. package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
  27. package/agent/__pycache__/curator.cpython-312.pyc +0 -0
  28. package/agent/__pycache__/display.cpython-312.pyc +0 -0
  29. package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
  30. package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
  31. package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
  32. package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
  33. package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
  34. package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
  35. package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
  36. package/agent/__pycache__/insights.cpython-312.pyc +0 -0
  37. package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
  38. package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
  39. package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
  40. package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
  41. package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
  42. package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
  43. package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
  44. package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
  45. package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
  46. package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
  47. package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
  48. package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
  49. package/agent/__pycache__/redact.cpython-312.pyc +0 -0
  50. package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
  51. package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
  52. package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
  53. package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
  54. package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
  55. package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
  56. package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
  57. package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
  58. package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
  59. package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
  60. package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
  61. package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
  62. package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
  63. package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
  64. package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
  65. package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
  66. package/agent/account_usage.py +326 -0
  67. package/agent/anthropic_adapter.py +2087 -0
  68. package/agent/async_utils.py +68 -0
  69. package/agent/auxiliary_client.py +4893 -0
  70. package/agent/bedrock_adapter.py +1276 -0
  71. package/agent/codex_responses_adapter.py +1084 -0
  72. package/agent/context_compressor.py +1583 -0
  73. package/agent/context_engine.py +211 -0
  74. package/agent/context_references.py +519 -0
  75. package/agent/copilot_acp_client.py +684 -0
  76. package/agent/credential_pool.py +1780 -0
  77. package/agent/credential_sources.py +449 -0
  78. package/agent/curator.py +1782 -0
  79. package/agent/curator_backup.py +694 -0
  80. package/agent/display.py +987 -0
  81. package/agent/error_classifier.py +1058 -0
  82. package/agent/file_safety.py +112 -0
  83. package/agent/gemini_cloudcode_adapter.py +909 -0
  84. package/agent/gemini_native_adapter.py +971 -0
  85. package/agent/gemini_schema.py +99 -0
  86. package/agent/google_code_assist.py +452 -0
  87. package/agent/google_oauth.py +1062 -0
  88. package/agent/i18n.py +258 -0
  89. package/agent/image_gen_provider.py +243 -0
  90. package/agent/image_gen_registry.py +145 -0
  91. package/agent/image_routing.py +301 -0
  92. package/agent/insights.py +931 -0
  93. package/agent/lmstudio_reasoning.py +48 -0
  94. package/agent/lsp/__init__.py +106 -0
  95. package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
  97. package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
  98. package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
  99. package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
  100. package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
  101. package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
  102. package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
  103. package/agent/lsp/cli.py +308 -0
  104. package/agent/lsp/client.py +930 -0
  105. package/agent/lsp/eventlog.py +213 -0
  106. package/agent/lsp/install.py +376 -0
  107. package/agent/lsp/manager.py +644 -0
  108. package/agent/lsp/protocol.py +196 -0
  109. package/agent/lsp/range_shift.py +149 -0
  110. package/agent/lsp/reporter.py +78 -0
  111. package/agent/lsp/servers.py +1040 -0
  112. package/agent/lsp/workspace.py +223 -0
  113. package/agent/manual_compression_feedback.py +49 -0
  114. package/agent/markdown_tables.py +309 -0
  115. package/agent/memory_manager.py +556 -0
  116. package/agent/memory_provider.py +279 -0
  117. package/agent/model_metadata.py +1827 -0
  118. package/agent/models_dev.py +724 -0
  119. package/agent/moonshot_schema.py +231 -0
  120. package/agent/nous_rate_guard.py +326 -0
  121. package/agent/onboarding.py +193 -0
  122. package/agent/plugin_llm.py +1046 -0
  123. package/agent/portal_tags.py +64 -0
  124. package/agent/prompt_builder.py +1457 -0
  125. package/agent/prompt_caching.py +79 -0
  126. package/agent/rate_limit_tracker.py +246 -0
  127. package/agent/redact.py +403 -0
  128. package/agent/retry_utils.py +57 -0
  129. package/agent/shell_hooks.py +837 -0
  130. package/agent/skill_commands.py +502 -0
  131. package/agent/skill_preprocessing.py +131 -0
  132. package/agent/skill_utils.py +512 -0
  133. package/agent/subdirectory_hints.py +224 -0
  134. package/agent/think_scrubber.py +386 -0
  135. package/agent/title_generator.py +171 -0
  136. package/agent/tool_guardrails.py +458 -0
  137. package/agent/tool_result_classification.py +26 -0
  138. package/agent/trajectory.py +56 -0
  139. package/agent/transports/__init__.py +68 -0
  140. package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
  141. package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
  142. package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
  143. package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
  144. package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
  145. package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
  146. package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
  147. package/agent/transports/anthropic.py +179 -0
  148. package/agent/transports/base.py +89 -0
  149. package/agent/transports/bedrock.py +154 -0
  150. package/agent/transports/chat_completions.py +614 -0
  151. package/agent/transports/codex.py +283 -0
  152. package/agent/transports/codex_app_server.py +368 -0
  153. package/agent/transports/codex_app_server_session.py +810 -0
  154. package/agent/transports/codex_event_projector.py +312 -0
  155. package/agent/transports/hermes_tools_mcp_server.py +233 -0
  156. package/agent/transports/types.py +162 -0
  157. package/agent/usage_pricing.py +877 -0
  158. package/agent/video_gen_provider.py +300 -0
  159. package/agent/video_gen_registry.py +117 -0
  160. package/agent/web_search_provider.py +221 -0
  161. package/agent/web_search_registry.py +262 -0
  162. package/assets/banner.png +0 -0
  163. package/batch_runner.py +1303 -0
  164. package/bin/calvyn.js +67 -0
  165. package/calvyn_bootstrap.py +130 -0
  166. package/calvyn_constants.py +346 -0
  167. package/calvyn_logging.py +390 -0
  168. package/calvyn_state.py +2967 -0
  169. package/calvyn_time.py +105 -0
  170. package/cli.py +14160 -0
  171. package/cron/__init__.py +42 -0
  172. package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
  173. package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
  174. package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
  175. package/cron/jobs.py +1160 -0
  176. package/cron/scheduler.py +1832 -0
  177. package/gateway/__init__.py +35 -0
  178. package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  179. package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
  180. package/gateway/__pycache__/config.cpython-312.pyc +0 -0
  181. package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
  182. package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
  183. package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
  184. package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
  185. package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
  186. package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
  187. package/gateway/__pycache__/run.cpython-312.pyc +0 -0
  188. package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
  189. package/gateway/__pycache__/session.cpython-312.pyc +0 -0
  190. package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
  191. package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
  192. package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
  193. package/gateway/__pycache__/status.cpython-312.pyc +0 -0
  194. package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
  195. package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
  196. package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
  197. package/gateway/builtin_hooks/__init__.py +1 -0
  198. package/gateway/channel_directory.py +357 -0
  199. package/gateway/config.py +1873 -0
  200. package/gateway/delivery.py +258 -0
  201. package/gateway/display_config.py +206 -0
  202. package/gateway/hooks.py +210 -0
  203. package/gateway/mirror.py +179 -0
  204. package/gateway/pairing.py +322 -0
  205. package/gateway/platform_registry.py +260 -0
  206. package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
  207. package/gateway/platforms/__init__.py +45 -0
  208. package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
  209. package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
  210. package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
  211. package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
  212. package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
  213. package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
  214. package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
  215. package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
  216. package/gateway/platforms/_http_client_limits.py +84 -0
  217. package/gateway/platforms/api_server.py +3488 -0
  218. package/gateway/platforms/base.py +3747 -0
  219. package/gateway/platforms/bluebubbles.py +937 -0
  220. package/gateway/platforms/dingtalk.py +1473 -0
  221. package/gateway/platforms/discord.py +5584 -0
  222. package/gateway/platforms/email.py +773 -0
  223. package/gateway/platforms/feishu.py +5059 -0
  224. package/gateway/platforms/feishu_comment.py +1382 -0
  225. package/gateway/platforms/feishu_comment_rules.py +430 -0
  226. package/gateway/platforms/helpers.py +279 -0
  227. package/gateway/platforms/homeassistant.py +449 -0
  228. package/gateway/platforms/matrix.py +2777 -0
  229. package/gateway/platforms/mattermost.py +852 -0
  230. package/gateway/platforms/msgraph_webhook.py +397 -0
  231. package/gateway/platforms/qqbot/__init__.py +91 -0
  232. package/gateway/platforms/qqbot/adapter.py +3072 -0
  233. package/gateway/platforms/qqbot/chunked_upload.py +602 -0
  234. package/gateway/platforms/qqbot/constants.py +74 -0
  235. package/gateway/platforms/qqbot/crypto.py +45 -0
  236. package/gateway/platforms/qqbot/keyboards.py +473 -0
  237. package/gateway/platforms/qqbot/onboard.py +220 -0
  238. package/gateway/platforms/qqbot/utils.py +71 -0
  239. package/gateway/platforms/signal.py +1518 -0
  240. package/gateway/platforms/signal_rate_limit.py +369 -0
  241. package/gateway/platforms/slack.py +3028 -0
  242. package/gateway/platforms/sms.py +377 -0
  243. package/gateway/platforms/telegram.py +4836 -0
  244. package/gateway/platforms/telegram_network.py +249 -0
  245. package/gateway/platforms/webhook.py +806 -0
  246. package/gateway/platforms/wecom.py +1610 -0
  247. package/gateway/platforms/wecom_callback.py +403 -0
  248. package/gateway/platforms/wecom_crypto.py +142 -0
  249. package/gateway/platforms/weixin.py +2170 -0
  250. package/gateway/platforms/whatsapp.py +1283 -0
  251. package/gateway/platforms/yuanbao.py +4873 -0
  252. package/gateway/platforms/yuanbao_media.py +645 -0
  253. package/gateway/platforms/yuanbao_proto.py +1209 -0
  254. package/gateway/platforms/yuanbao_sticker.py +558 -0
  255. package/gateway/restart.py +20 -0
  256. package/gateway/run.py +17074 -0
  257. package/gateway/runtime_footer.py +150 -0
  258. package/gateway/session.py +1399 -0
  259. package/gateway/session_context.py +156 -0
  260. package/gateway/shutdown_forensics.py +462 -0
  261. package/gateway/slash_access.py +229 -0
  262. package/gateway/status.py +972 -0
  263. package/gateway/sticker_cache.py +111 -0
  264. package/gateway/stream_consumer.py +1286 -0
  265. package/gateway/whatsapp_identity.py +156 -0
  266. package/hermes_cli/__init__.py +47 -0
  267. package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  268. package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
  269. package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
  270. package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
  271. package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
  272. package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
  273. package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
  274. package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
  275. package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
  276. package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
  277. package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
  278. package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
  279. package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
  280. package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
  281. package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
  282. package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
  283. package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
  284. package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
  285. package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
  286. package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
  287. package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
  288. package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
  289. package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
  290. package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
  291. package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
  292. package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
  293. package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
  294. package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
  295. package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
  296. package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
  297. package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
  298. package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
  299. package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
  300. package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
  301. package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
  302. package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
  303. package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
  304. package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
  305. package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
  306. package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
  307. package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
  308. package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
  309. package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
  310. package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
  311. package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
  312. package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
  313. package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
  314. package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
  315. package/hermes_cli/_parser.py +365 -0
  316. package/hermes_cli/_subprocess_compat.py +175 -0
  317. package/hermes_cli/auth.py +6299 -0
  318. package/hermes_cli/auth_commands.py +749 -0
  319. package/hermes_cli/azure_detect.py +300 -0
  320. package/hermes_cli/backup.py +938 -0
  321. package/hermes_cli/banner.py +703 -0
  322. package/hermes_cli/browser_connect.py +139 -0
  323. package/hermes_cli/callbacks.py +243 -0
  324. package/hermes_cli/checkpoints.py +244 -0
  325. package/hermes_cli/claw.py +810 -0
  326. package/hermes_cli/cli_output.py +78 -0
  327. package/hermes_cli/clipboard.py +495 -0
  328. package/hermes_cli/codex_models.py +198 -0
  329. package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
  330. package/hermes_cli/codex_runtime_switch.py +266 -0
  331. package/hermes_cli/colors.py +38 -0
  332. package/hermes_cli/commands.py +1728 -0
  333. package/hermes_cli/completion.py +315 -0
  334. package/hermes_cli/config.py +5382 -0
  335. package/hermes_cli/copilot_auth.py +392 -0
  336. package/hermes_cli/cron.py +313 -0
  337. package/hermes_cli/curator.py +598 -0
  338. package/hermes_cli/curses_ui.py +472 -0
  339. package/hermes_cli/debug.py +747 -0
  340. package/hermes_cli/default_soul.py +11 -0
  341. package/hermes_cli/dep_ensure.py +107 -0
  342. package/hermes_cli/dingtalk_auth.py +293 -0
  343. package/hermes_cli/doctor.py +1863 -0
  344. package/hermes_cli/dump.py +326 -0
  345. package/hermes_cli/env_loader.py +175 -0
  346. package/hermes_cli/fallback_cmd.py +361 -0
  347. package/hermes_cli/gateway.py +5422 -0
  348. package/hermes_cli/gateway_windows.py +692 -0
  349. package/hermes_cli/goals.py +757 -0
  350. package/hermes_cli/hooks.py +385 -0
  351. package/hermes_cli/inventory.py +240 -0
  352. package/hermes_cli/kanban.py +2252 -0
  353. package/hermes_cli/kanban_db.py +4840 -0
  354. package/hermes_cli/kanban_diagnostics.py +776 -0
  355. package/hermes_cli/kanban_specify.py +266 -0
  356. package/hermes_cli/logs.py +391 -0
  357. package/hermes_cli/main.py +12396 -0
  358. package/hermes_cli/mcp_config.py +781 -0
  359. package/hermes_cli/memory_setup.py +465 -0
  360. package/hermes_cli/model_catalog.py +330 -0
  361. package/hermes_cli/model_normalize.py +473 -0
  362. package/hermes_cli/model_switch.py +1777 -0
  363. package/hermes_cli/models.py +3789 -0
  364. package/hermes_cli/nous_subscription.py +799 -0
  365. package/hermes_cli/oneshot.py +351 -0
  366. package/hermes_cli/pairing.py +115 -0
  367. package/hermes_cli/platforms.py +83 -0
  368. package/hermes_cli/plugins.py +1562 -0
  369. package/hermes_cli/plugins_cmd.py +1587 -0
  370. package/hermes_cli/profile_distribution.py +703 -0
  371. package/hermes_cli/profiles.py +1319 -0
  372. package/hermes_cli/providers.py +720 -0
  373. package/hermes_cli/proxy/__init__.py +20 -0
  374. package/hermes_cli/proxy/adapters/__init__.py +35 -0
  375. package/hermes_cli/proxy/adapters/base.py +94 -0
  376. package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
  377. package/hermes_cli/proxy/cli.py +141 -0
  378. package/hermes_cli/proxy/server.py +265 -0
  379. package/hermes_cli/pt_input_extras.py +83 -0
  380. package/hermes_cli/pty_bridge.py +237 -0
  381. package/hermes_cli/relaunch.py +205 -0
  382. package/hermes_cli/runtime_provider.py +1428 -0
  383. package/hermes_cli/security_advisories.py +452 -0
  384. package/hermes_cli/setup.py +3559 -0
  385. package/hermes_cli/skills_config.py +177 -0
  386. package/hermes_cli/skills_hub.py +1595 -0
  387. package/hermes_cli/skin_engine.py +929 -0
  388. package/hermes_cli/slack_cli.py +160 -0
  389. package/hermes_cli/status.py +550 -0
  390. package/hermes_cli/stdio.py +252 -0
  391. package/hermes_cli/timeouts.py +82 -0
  392. package/hermes_cli/tips.py +487 -0
  393. package/hermes_cli/tools_config.py +3151 -0
  394. package/hermes_cli/uninstall.py +681 -0
  395. package/hermes_cli/vercel_auth.py +70 -0
  396. package/hermes_cli/voice.py +846 -0
  397. package/hermes_cli/web_server.py +4438 -0
  398. package/hermes_cli/webhook.py +275 -0
  399. package/locales/af.yaml +350 -0
  400. package/locales/de.yaml +350 -0
  401. package/locales/en.yaml +365 -0
  402. package/locales/es.yaml +350 -0
  403. package/locales/fr.yaml +350 -0
  404. package/locales/ga.yaml +354 -0
  405. package/locales/hu.yaml +350 -0
  406. package/locales/it.yaml +350 -0
  407. package/locales/ja.yaml +350 -0
  408. package/locales/ko.yaml +350 -0
  409. package/locales/pt.yaml +350 -0
  410. package/locales/ru.yaml +350 -0
  411. package/locales/tr.yaml +350 -0
  412. package/locales/uk.yaml +350 -0
  413. package/locales/zh-hant.yaml +350 -0
  414. package/locales/zh.yaml +350 -0
  415. package/mcp_serve.py +898 -0
  416. package/model_tools.py +899 -0
  417. package/optional-skills/DESCRIPTION.md +24 -0
  418. package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
  419. package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
  420. package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
  421. package/optional-skills/blockchain/evm/SKILL.md +211 -0
  422. package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
  423. package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
  424. package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
  425. package/optional-skills/blockchain/solana/SKILL.md +208 -0
  426. package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
  427. package/optional-skills/communication/DESCRIPTION.md +1 -0
  428. package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
  429. package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
  430. package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
  431. package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
  432. package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
  433. package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
  434. package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
  435. package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
  436. package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
  437. package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
  438. package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
  439. package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
  440. package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
  441. package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
  442. package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
  443. package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
  444. package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
  445. package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
  446. package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
  447. package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
  448. package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
  449. package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
  450. package/optional-skills/creative/hyperframes/SKILL.md +191 -0
  451. package/optional-skills/creative/hyperframes/references/cli.md +185 -0
  452. package/optional-skills/creative/hyperframes/references/composition.md +129 -0
  453. package/optional-skills/creative/hyperframes/references/features.md +289 -0
  454. package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
  455. package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
  456. package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
  457. package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
  458. package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
  459. package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
  460. package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
  461. package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
  462. package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
  463. package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
  464. package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
  465. package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
  466. package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
  467. package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
  468. package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
  469. package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
  470. package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
  471. package/optional-skills/creative/meme-generation/SKILL.md +130 -0
  472. package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
  473. package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
  474. package/optional-skills/devops/cli/SKILL.md +156 -0
  475. package/optional-skills/devops/cli/references/app-discovery.md +112 -0
  476. package/optional-skills/devops/cli/references/authentication.md +59 -0
  477. package/optional-skills/devops/cli/references/cli-reference.md +104 -0
  478. package/optional-skills/devops/cli/references/running-apps.md +171 -0
  479. package/optional-skills/devops/docker-management/SKILL.md +281 -0
  480. package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
  481. package/optional-skills/devops/watchers/SKILL.md +112 -0
  482. package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
  483. package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
  484. package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
  485. package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
  486. package/optional-skills/dogfood/DESCRIPTION.md +3 -0
  487. package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
  488. package/optional-skills/email/agentmail/SKILL.md +126 -0
  489. package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
  490. package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
  491. package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
  492. package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
  493. package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
  494. package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
  495. package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
  496. package/optional-skills/finance/dcf-model/requirements.txt +7 -0
  497. package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
  498. package/optional-skills/finance/excel-author/SKILL.md +244 -0
  499. package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
  500. package/optional-skills/finance/lbo-model/SKILL.md +291 -0
  501. package/optional-skills/finance/merger-model/SKILL.md +144 -0
  502. package/optional-skills/finance/pptx-author/SKILL.md +173 -0
  503. package/optional-skills/finance/stocks/SKILL.md +95 -0
  504. package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
  505. package/optional-skills/health/DESCRIPTION.md +1 -0
  506. package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
  507. package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
  508. package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
  509. package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
  510. package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
  511. package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
  512. package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
  513. package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
  514. package/optional-skills/mcp/DESCRIPTION.md +3 -0
  515. package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
  516. package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
  517. package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
  518. package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
  519. package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
  520. package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
  521. package/optional-skills/mcp/mcporter/SKILL.md +123 -0
  522. package/optional-skills/migration/DESCRIPTION.md +2 -0
  523. package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
  524. package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
  525. package/optional-skills/mlops/accelerate/SKILL.md +336 -0
  526. package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
  527. package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
  528. package/optional-skills/mlops/accelerate/references/performance.md +525 -0
  529. package/optional-skills/mlops/chroma/SKILL.md +410 -0
  530. package/optional-skills/mlops/chroma/references/integration.md +38 -0
  531. package/optional-skills/mlops/clip/SKILL.md +257 -0
  532. package/optional-skills/mlops/clip/references/applications.md +207 -0
  533. package/optional-skills/mlops/faiss/SKILL.md +225 -0
  534. package/optional-skills/mlops/faiss/references/index_types.md +280 -0
  535. package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
  536. package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
  537. package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
  538. package/optional-skills/mlops/guidance/SKILL.md +576 -0
  539. package/optional-skills/mlops/guidance/references/backends.md +554 -0
  540. package/optional-skills/mlops/guidance/references/constraints.md +674 -0
  541. package/optional-skills/mlops/guidance/references/examples.md +767 -0
  542. package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
  543. package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
  544. package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
  545. package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
  546. package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
  547. package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
  548. package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
  549. package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
  550. package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
  551. package/optional-skills/mlops/instructor/SKILL.md +744 -0
  552. package/optional-skills/mlops/instructor/references/examples.md +107 -0
  553. package/optional-skills/mlops/instructor/references/providers.md +70 -0
  554. package/optional-skills/mlops/instructor/references/validation.md +606 -0
  555. package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
  556. package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
  557. package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
  558. package/optional-skills/mlops/llava/SKILL.md +308 -0
  559. package/optional-skills/mlops/llava/references/training.md +197 -0
  560. package/optional-skills/mlops/modal/SKILL.md +345 -0
  561. package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
  562. package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
  563. package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
  564. package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
  565. package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
  566. package/optional-skills/mlops/peft/SKILL.md +435 -0
  567. package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
  568. package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
  569. package/optional-skills/mlops/pinecone/SKILL.md +362 -0
  570. package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
  571. package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
  572. package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
  573. package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
  574. package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
  575. package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
  576. package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
  577. package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
  578. package/optional-skills/mlops/qdrant/SKILL.md +497 -0
  579. package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
  580. package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
  581. package/optional-skills/mlops/saelens/SKILL.md +390 -0
  582. package/optional-skills/mlops/saelens/references/README.md +69 -0
  583. package/optional-skills/mlops/saelens/references/api.md +333 -0
  584. package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
  585. package/optional-skills/mlops/simpo/SKILL.md +223 -0
  586. package/optional-skills/mlops/simpo/references/datasets.md +478 -0
  587. package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
  588. package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
  589. package/optional-skills/mlops/slime/SKILL.md +468 -0
  590. package/optional-skills/mlops/slime/references/api-reference.md +392 -0
  591. package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
  592. package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
  593. package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
  594. package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
  595. package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
  596. package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
  597. package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
  598. package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
  599. package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
  600. package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
  601. package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
  602. package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
  603. package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
  604. package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
  605. package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
  606. package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
  607. package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
  608. package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
  609. package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
  610. package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
  611. package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
  612. package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
  613. package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
  614. package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
  615. package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
  616. package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
  617. package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
  618. package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
  619. package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
  620. package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
  621. package/optional-skills/mlops/whisper/SKILL.md +321 -0
  622. package/optional-skills/mlops/whisper/references/languages.md +189 -0
  623. package/optional-skills/productivity/canvas/SKILL.md +98 -0
  624. package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
  625. package/optional-skills/productivity/here-now/SKILL.md +217 -0
  626. package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
  627. package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
  628. package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
  629. package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
  630. package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
  631. package/optional-skills/productivity/shop-app/SKILL.md +340 -0
  632. package/optional-skills/productivity/shopify/SKILL.md +373 -0
  633. package/optional-skills/productivity/siyuan/SKILL.md +298 -0
  634. package/optional-skills/productivity/telephony/SKILL.md +418 -0
  635. package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
  636. package/optional-skills/research/bioinformatics/SKILL.md +235 -0
  637. package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
  638. package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
  639. package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
  640. package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
  641. package/optional-skills/research/domain-intel/SKILL.md +97 -0
  642. package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
  643. package/optional-skills/research/drug-discovery/SKILL.md +227 -0
  644. package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
  645. package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
  646. package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
  647. package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
  648. package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
  649. package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
  650. package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
  651. package/optional-skills/research/osint-investigation/SKILL.md +277 -0
  652. package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
  653. package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
  654. package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
  655. package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
  656. package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
  657. package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
  658. package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
  659. package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
  660. package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
  661. package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
  662. package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
  663. package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
  664. package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
  665. package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
  666. package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
  667. package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
  668. package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
  669. package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
  670. package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
  671. package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
  672. package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
  673. package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
  674. package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
  675. package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
  676. package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
  677. package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
  678. package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
  679. package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
  680. package/optional-skills/research/parallel-cli/SKILL.md +391 -0
  681. package/optional-skills/research/qmd/SKILL.md +441 -0
  682. package/optional-skills/research/scrapling/SKILL.md +336 -0
  683. package/optional-skills/research/searxng-search/SKILL.md +212 -0
  684. package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
  685. package/optional-skills/security/1password/SKILL.md +163 -0
  686. package/optional-skills/security/1password/references/cli-examples.md +31 -0
  687. package/optional-skills/security/1password/references/get-started.md +21 -0
  688. package/optional-skills/security/DESCRIPTION.md +3 -0
  689. package/optional-skills/security/oss-forensics/SKILL.md +423 -0
  690. package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
  691. package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
  692. package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
  693. package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
  694. package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
  695. package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
  696. package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
  697. package/optional-skills/security/sherlock/SKILL.md +193 -0
  698. package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
  699. package/optional-skills/web-development/DESCRIPTION.md +5 -0
  700. package/optional-skills/web-development/page-agent/SKILL.md +190 -0
  701. package/package.json +78 -0
  702. package/plugins/__init__.py +1 -0
  703. package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
  704. package/plugins/context_engine/__init__.py +219 -0
  705. package/plugins/disk-cleanup/README.md +51 -0
  706. package/plugins/disk-cleanup/__init__.py +316 -0
  707. package/plugins/disk-cleanup/disk_cleanup.py +497 -0
  708. package/plugins/disk-cleanup/plugin.yaml +7 -0
  709. package/plugins/example-dashboard/dashboard/manifest.json +14 -0
  710. package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
  711. package/plugins/google_meet/README.md +131 -0
  712. package/plugins/google_meet/SKILL.md +148 -0
  713. package/plugins/google_meet/__init__.py +103 -0
  714. package/plugins/google_meet/audio_bridge.py +244 -0
  715. package/plugins/google_meet/cli.py +479 -0
  716. package/plugins/google_meet/meet_bot.py +852 -0
  717. package/plugins/google_meet/node/__init__.py +54 -0
  718. package/plugins/google_meet/node/cli.py +125 -0
  719. package/plugins/google_meet/node/client.py +107 -0
  720. package/plugins/google_meet/node/protocol.py +124 -0
  721. package/plugins/google_meet/node/registry.py +113 -0
  722. package/plugins/google_meet/node/server.py +201 -0
  723. package/plugins/google_meet/plugin.yaml +16 -0
  724. package/plugins/google_meet/process_manager.py +324 -0
  725. package/plugins/google_meet/realtime/__init__.py +10 -0
  726. package/plugins/google_meet/realtime/openai_client.py +332 -0
  727. package/plugins/google_meet/tools.py +348 -0
  728. package/plugins/hermes-achievements/LICENSE +21 -0
  729. package/plugins/hermes-achievements/README.md +150 -0
  730. package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
  731. package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
  732. package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
  733. package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
  734. package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
  735. package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
  736. package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
  737. package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
  738. package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
  739. package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
  740. package/plugins/image_gen/openai/__init__.py +303 -0
  741. package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
  742. package/plugins/image_gen/openai/plugin.yaml +7 -0
  743. package/plugins/image_gen/openai-codex/__init__.py +378 -0
  744. package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  745. package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
  746. package/plugins/image_gen/xai/__init__.py +316 -0
  747. package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  748. package/plugins/image_gen/xai/plugin.yaml +7 -0
  749. package/plugins/kanban/dashboard/dist/index.js +3143 -0
  750. package/plugins/kanban/dashboard/dist/style.css +1500 -0
  751. package/plugins/kanban/dashboard/manifest.json +14 -0
  752. package/plugins/kanban/dashboard/plugin_api.py +1612 -0
  753. package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
  754. package/plugins/memory/__init__.py +408 -0
  755. package/plugins/memory/byterover/README.md +41 -0
  756. package/plugins/memory/byterover/__init__.py +384 -0
  757. package/plugins/memory/byterover/plugin.yaml +9 -0
  758. package/plugins/memory/hindsight/README.md +138 -0
  759. package/plugins/memory/hindsight/__init__.py +1758 -0
  760. package/plugins/memory/hindsight/plugin.yaml +8 -0
  761. package/plugins/memory/holographic/README.md +36 -0
  762. package/plugins/memory/holographic/__init__.py +409 -0
  763. package/plugins/memory/holographic/holographic.py +203 -0
  764. package/plugins/memory/holographic/plugin.yaml +5 -0
  765. package/plugins/memory/holographic/retrieval.py +593 -0
  766. package/plugins/memory/holographic/store.py +579 -0
  767. package/plugins/memory/honcho/README.md +328 -0
  768. package/plugins/memory/honcho/__init__.py +1329 -0
  769. package/plugins/memory/honcho/cli.py +1452 -0
  770. package/plugins/memory/honcho/client.py +784 -0
  771. package/plugins/memory/honcho/plugin.yaml +7 -0
  772. package/plugins/memory/honcho/session.py +1255 -0
  773. package/plugins/memory/mem0/README.md +38 -0
  774. package/plugins/memory/mem0/__init__.py +374 -0
  775. package/plugins/memory/mem0/plugin.yaml +5 -0
  776. package/plugins/memory/openviking/README.md +40 -0
  777. package/plugins/memory/openviking/__init__.py +945 -0
  778. package/plugins/memory/openviking/plugin.yaml +9 -0
  779. package/plugins/memory/retaindb/README.md +40 -0
  780. package/plugins/memory/retaindb/__init__.py +767 -0
  781. package/plugins/memory/retaindb/plugin.yaml +7 -0
  782. package/plugins/memory/supermemory/README.md +99 -0
  783. package/plugins/memory/supermemory/__init__.py +792 -0
  784. package/plugins/memory/supermemory/plugin.yaml +5 -0
  785. package/plugins/model-providers/README.md +70 -0
  786. package/plugins/model-providers/ai-gateway/__init__.py +43 -0
  787. package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  788. package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
  789. package/plugins/model-providers/alibaba/__init__.py +13 -0
  790. package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
  791. package/plugins/model-providers/alibaba/plugin.yaml +5 -0
  792. package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
  793. package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
  794. package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
  795. package/plugins/model-providers/anthropic/__init__.py +52 -0
  796. package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
  797. package/plugins/model-providers/anthropic/plugin.yaml +5 -0
  798. package/plugins/model-providers/arcee/__init__.py +13 -0
  799. package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
  800. package/plugins/model-providers/arcee/plugin.yaml +5 -0
  801. package/plugins/model-providers/azure-foundry/__init__.py +21 -0
  802. package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
  803. package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
  804. package/plugins/model-providers/bedrock/__init__.py +29 -0
  805. package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
  806. package/plugins/model-providers/bedrock/plugin.yaml +5 -0
  807. package/plugins/model-providers/copilot/__init__.py +58 -0
  808. package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
  809. package/plugins/model-providers/copilot/plugin.yaml +5 -0
  810. package/plugins/model-providers/copilot-acp/__init__.py +34 -0
  811. package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
  812. package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
  813. package/plugins/model-providers/custom/__init__.py +68 -0
  814. package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
  815. package/plugins/model-providers/custom/plugin.yaml +5 -0
  816. package/plugins/model-providers/deepseek/__init__.py +99 -0
  817. package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
  818. package/plugins/model-providers/deepseek/plugin.yaml +5 -0
  819. package/plugins/model-providers/gemini/__init__.py +72 -0
  820. package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
  821. package/plugins/model-providers/gemini/plugin.yaml +5 -0
  822. package/plugins/model-providers/gmi/__init__.py +31 -0
  823. package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
  824. package/plugins/model-providers/gmi/plugin.yaml +5 -0
  825. package/plugins/model-providers/huggingface/__init__.py +20 -0
  826. package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
  827. package/plugins/model-providers/huggingface/plugin.yaml +5 -0
  828. package/plugins/model-providers/kilocode/__init__.py +14 -0
  829. package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
  830. package/plugins/model-providers/kilocode/plugin.yaml +5 -0
  831. package/plugins/model-providers/kimi-coding/__init__.py +71 -0
  832. package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
  833. package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
  834. package/plugins/model-providers/minimax/__init__.py +45 -0
  835. package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
  836. package/plugins/model-providers/minimax/plugin.yaml +5 -0
  837. package/plugins/model-providers/nous/__init__.py +54 -0
  838. package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
  839. package/plugins/model-providers/nous/plugin.yaml +5 -0
  840. package/plugins/model-providers/novita/__init__.py +27 -0
  841. package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
  842. package/plugins/model-providers/novita/plugin.yaml +5 -0
  843. package/plugins/model-providers/nvidia/__init__.py +21 -0
  844. package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
  845. package/plugins/model-providers/nvidia/plugin.yaml +5 -0
  846. package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
  847. package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
  848. package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
  849. package/plugins/model-providers/openai-codex/__init__.py +15 -0
  850. package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  851. package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
  852. package/plugins/model-providers/opencode-zen/__init__.py +30 -0
  853. package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
  854. package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
  855. package/plugins/model-providers/openrouter/__init__.py +115 -0
  856. package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
  857. package/plugins/model-providers/openrouter/plugin.yaml +5 -0
  858. package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
  859. package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
  860. package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
  861. package/plugins/model-providers/stepfun/__init__.py +14 -0
  862. package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
  863. package/plugins/model-providers/stepfun/plugin.yaml +5 -0
  864. package/plugins/model-providers/xai/__init__.py +15 -0
  865. package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  866. package/plugins/model-providers/xai/plugin.yaml +5 -0
  867. package/plugins/model-providers/xiaomi/__init__.py +14 -0
  868. package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
  869. package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
  870. package/plugins/model-providers/zai/__init__.py +21 -0
  871. package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
  872. package/plugins/model-providers/zai/plugin.yaml +5 -0
  873. package/plugins/observability/langfuse/README.md +53 -0
  874. package/plugins/observability/langfuse/__init__.py +1004 -0
  875. package/plugins/observability/langfuse/plugin.yaml +14 -0
  876. package/plugins/platforms/google_chat/__init__.py +3 -0
  877. package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
  878. package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
  879. package/plugins/platforms/google_chat/adapter.py +3343 -0
  880. package/plugins/platforms/google_chat/oauth.py +639 -0
  881. package/plugins/platforms/google_chat/plugin.yaml +39 -0
  882. package/plugins/platforms/irc/__init__.py +3 -0
  883. package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
  884. package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
  885. package/plugins/platforms/irc/adapter.py +969 -0
  886. package/plugins/platforms/irc/plugin.yaml +54 -0
  887. package/plugins/platforms/line/__init__.py +3 -0
  888. package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
  889. package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
  890. package/plugins/platforms/line/adapter.py +1639 -0
  891. package/plugins/platforms/line/plugin.yaml +65 -0
  892. package/plugins/platforms/simplex/__init__.py +3 -0
  893. package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
  894. package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
  895. package/plugins/platforms/simplex/adapter.py +746 -0
  896. package/plugins/platforms/simplex/plugin.yaml +37 -0
  897. package/plugins/platforms/teams/__init__.py +3 -0
  898. package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
  899. package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
  900. package/plugins/platforms/teams/adapter.py +1188 -0
  901. package/plugins/platforms/teams/plugin.yaml +48 -0
  902. package/plugins/spotify/__init__.py +66 -0
  903. package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
  904. package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
  905. package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
  906. package/plugins/spotify/client.py +435 -0
  907. package/plugins/spotify/plugin.yaml +13 -0
  908. package/plugins/spotify/tools.py +454 -0
  909. package/plugins/teams_pipeline/__init__.py +23 -0
  910. package/plugins/teams_pipeline/cli.py +463 -0
  911. package/plugins/teams_pipeline/meetings.py +333 -0
  912. package/plugins/teams_pipeline/models.py +350 -0
  913. package/plugins/teams_pipeline/pipeline.py +692 -0
  914. package/plugins/teams_pipeline/plugin.yaml +9 -0
  915. package/plugins/teams_pipeline/runtime.py +135 -0
  916. package/plugins/teams_pipeline/store.py +194 -0
  917. package/plugins/teams_pipeline/subscriptions.py +249 -0
  918. package/plugins/video_gen/fal/__init__.py +523 -0
  919. package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
  920. package/plugins/video_gen/fal/plugin.yaml +7 -0
  921. package/plugins/video_gen/xai/__init__.py +441 -0
  922. package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  923. package/plugins/video_gen/xai/plugin.yaml +7 -0
  924. package/plugins/web/__init__.py +7 -0
  925. package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
  926. package/plugins/web/brave_free/__init__.py +14 -0
  927. package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
  928. package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
  929. package/plugins/web/brave_free/plugin.yaml +7 -0
  930. package/plugins/web/brave_free/provider.py +137 -0
  931. package/plugins/web/ddgs/__init__.py +15 -0
  932. package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
  933. package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
  934. package/plugins/web/ddgs/plugin.yaml +7 -0
  935. package/plugins/web/ddgs/provider.py +104 -0
  936. package/plugins/web/exa/__init__.py +15 -0
  937. package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
  938. package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
  939. package/plugins/web/exa/plugin.yaml +7 -0
  940. package/plugins/web/exa/provider.py +212 -0
  941. package/plugins/web/firecrawl/__init__.py +28 -0
  942. package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
  943. package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
  944. package/plugins/web/firecrawl/plugin.yaml +7 -0
  945. package/plugins/web/firecrawl/provider.py +773 -0
  946. package/plugins/web/parallel/__init__.py +16 -0
  947. package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
  948. package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
  949. package/plugins/web/parallel/plugin.yaml +7 -0
  950. package/plugins/web/parallel/provider.py +291 -0
  951. package/plugins/web/searxng/__init__.py +15 -0
  952. package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
  953. package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
  954. package/plugins/web/searxng/plugin.yaml +7 -0
  955. package/plugins/web/searxng/provider.py +140 -0
  956. package/plugins/web/tavily/__init__.py +15 -0
  957. package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
  958. package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
  959. package/plugins/web/tavily/plugin.yaml +7 -0
  960. package/plugins/web/tavily/provider.py +285 -0
  961. package/providers/README.md +78 -0
  962. package/providers/__init__.py +192 -0
  963. package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
  964. package/providers/__pycache__/base.cpython-312.pyc +0 -0
  965. package/providers/base.py +184 -0
  966. package/pyproject.toml +255 -0
  967. package/run_agent.py +16409 -0
  968. package/scripts/benchmark_browser_eval.py +138 -0
  969. package/scripts/build_model_catalog.py +95 -0
  970. package/scripts/build_skills_index.py +325 -0
  971. package/scripts/check-windows-footguns.py +624 -0
  972. package/scripts/contributor_audit.py +473 -0
  973. package/scripts/discord-voice-doctor.py +396 -0
  974. package/scripts/hermes-gateway +416 -0
  975. package/scripts/install.cmd +28 -0
  976. package/scripts/install.ps1 +1611 -0
  977. package/scripts/install.sh +2007 -0
  978. package/scripts/install_psutil_android.py +117 -0
  979. package/scripts/keystroke_diagnostic.py +81 -0
  980. package/scripts/kill_modal.sh +34 -0
  981. package/scripts/lib/node-bootstrap.sh +238 -0
  982. package/scripts/lint_diff.py +207 -0
  983. package/scripts/postinstall.js +150 -0
  984. package/scripts/profile-tui.py +626 -0
  985. package/scripts/release.py +1680 -0
  986. package/scripts/run_tests.sh +129 -0
  987. package/scripts/sample_and_compress.py +409 -0
  988. package/scripts/setup_open_webui.sh +349 -0
  989. package/scripts/whatsapp-bridge/allowlist.js +88 -0
  990. package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
  991. package/scripts/whatsapp-bridge/bridge.js +729 -0
  992. package/scripts/whatsapp-bridge/package-lock.json +2141 -0
  993. package/scripts/whatsapp-bridge/package.json +19 -0
  994. package/skills/apple/DESCRIPTION.md +2 -0
  995. package/skills/apple/apple-notes/SKILL.md +90 -0
  996. package/skills/apple/apple-reminders/SKILL.md +98 -0
  997. package/skills/apple/findmy/SKILL.md +131 -0
  998. package/skills/apple/imessage/SKILL.md +102 -0
  999. package/skills/apple/macos-computer-use/SKILL.md +201 -0
  1000. package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
  1001. package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
  1002. package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
  1003. package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
  1004. package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
  1005. package/skills/creative/DESCRIPTION.md +3 -0
  1006. package/skills/creative/architecture-diagram/SKILL.md +148 -0
  1007. package/skills/creative/architecture-diagram/templates/template.html +319 -0
  1008. package/skills/creative/ascii-art/SKILL.md +322 -0
  1009. package/skills/creative/ascii-video/README.md +290 -0
  1010. package/skills/creative/ascii-video/SKILL.md +241 -0
  1011. package/skills/creative/ascii-video/references/architecture.md +802 -0
  1012. package/skills/creative/ascii-video/references/composition.md +892 -0
  1013. package/skills/creative/ascii-video/references/effects.md +1865 -0
  1014. package/skills/creative/ascii-video/references/inputs.md +685 -0
  1015. package/skills/creative/ascii-video/references/optimization.md +688 -0
  1016. package/skills/creative/ascii-video/references/scenes.md +1011 -0
  1017. package/skills/creative/ascii-video/references/shaders.md +1385 -0
  1018. package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
  1019. package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
  1020. package/skills/creative/baoyu-comic/SKILL.md +247 -0
  1021. package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
  1022. package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
  1023. package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
  1024. package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
  1025. package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
  1026. package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
  1027. package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
  1028. package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
  1029. package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
  1030. package/skills/creative/baoyu-comic/references/character-template.md +180 -0
  1031. package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
  1032. package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
  1033. package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
  1034. package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
  1035. package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
  1036. package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
  1037. package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
  1038. package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
  1039. package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
  1040. package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
  1041. package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
  1042. package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
  1043. package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
  1044. package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
  1045. package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
  1046. package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
  1047. package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
  1048. package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
  1049. package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
  1050. package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
  1051. package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
  1052. package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
  1053. package/skills/creative/baoyu-comic/references/workflow.md +401 -0
  1054. package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
  1055. package/skills/creative/baoyu-infographic/SKILL.md +237 -0
  1056. package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
  1057. package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
  1058. package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
  1059. package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
  1060. package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
  1061. package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
  1062. package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
  1063. package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
  1064. package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
  1065. package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
  1066. package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
  1067. package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
  1068. package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
  1069. package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
  1070. package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
  1071. package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
  1072. package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
  1073. package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
  1074. package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
  1075. package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
  1076. package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
  1077. package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
  1078. package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
  1079. package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
  1080. package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
  1081. package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
  1082. package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
  1083. package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
  1084. package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
  1085. package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
  1086. package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
  1087. package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
  1088. package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
  1089. package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
  1090. package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
  1091. package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
  1092. package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
  1093. package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
  1094. package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
  1095. package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
  1096. package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
  1097. package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
  1098. package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
  1099. package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
  1100. package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
  1101. package/skills/creative/claude-design/SKILL.md +591 -0
  1102. package/skills/creative/comfyui/SKILL.md +612 -0
  1103. package/skills/creative/comfyui/references/official-cli.md +255 -0
  1104. package/skills/creative/comfyui/references/rest-api.md +312 -0
  1105. package/skills/creative/comfyui/references/template-integrity.md +243 -0
  1106. package/skills/creative/comfyui/references/workflow-format.md +226 -0
  1107. package/skills/creative/comfyui/scripts/_common.py +835 -0
  1108. package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
  1109. package/skills/creative/comfyui/scripts/check_deps.py +437 -0
  1110. package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
  1111. package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
  1112. package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
  1113. package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
  1114. package/skills/creative/comfyui/scripts/health_check.py +223 -0
  1115. package/skills/creative/comfyui/scripts/run_batch.py +243 -0
  1116. package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
  1117. package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
  1118. package/skills/creative/comfyui/tests/README.md +50 -0
  1119. package/skills/creative/comfyui/tests/conftest.py +64 -0
  1120. package/skills/creative/comfyui/tests/pytest.ini +5 -0
  1121. package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
  1122. package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
  1123. package/skills/creative/comfyui/tests/test_common.py +447 -0
  1124. package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
  1125. package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
  1126. package/skills/creative/comfyui/workflows/README.md +86 -0
  1127. package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
  1128. package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
  1129. package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
  1130. package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
  1131. package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
  1132. package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
  1133. package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
  1134. package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
  1135. package/skills/creative/creative-ideation/SKILL.md +152 -0
  1136. package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
  1137. package/skills/creative/design-md/SKILL.md +199 -0
  1138. package/skills/creative/design-md/templates/starter.md +99 -0
  1139. package/skills/creative/excalidraw/SKILL.md +199 -0
  1140. package/skills/creative/excalidraw/references/colors.md +44 -0
  1141. package/skills/creative/excalidraw/references/dark-mode.md +68 -0
  1142. package/skills/creative/excalidraw/references/examples.md +141 -0
  1143. package/skills/creative/excalidraw/scripts/upload.py +133 -0
  1144. package/skills/creative/humanizer/LICENSE +21 -0
  1145. package/skills/creative/humanizer/SKILL.md +578 -0
  1146. package/skills/creative/manim-video/README.md +23 -0
  1147. package/skills/creative/manim-video/SKILL.md +269 -0
  1148. package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
  1149. package/skills/creative/manim-video/references/animations.md +282 -0
  1150. package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
  1151. package/skills/creative/manim-video/references/decorations.md +202 -0
  1152. package/skills/creative/manim-video/references/equations.md +216 -0
  1153. package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
  1154. package/skills/creative/manim-video/references/mobjects.md +333 -0
  1155. package/skills/creative/manim-video/references/paper-explainer.md +255 -0
  1156. package/skills/creative/manim-video/references/production-quality.md +190 -0
  1157. package/skills/creative/manim-video/references/rendering.md +185 -0
  1158. package/skills/creative/manim-video/references/scene-planning.md +118 -0
  1159. package/skills/creative/manim-video/references/troubleshooting.md +135 -0
  1160. package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
  1161. package/skills/creative/manim-video/references/visual-design.md +124 -0
  1162. package/skills/creative/manim-video/scripts/setup.sh +14 -0
  1163. package/skills/creative/p5js/README.md +64 -0
  1164. package/skills/creative/p5js/SKILL.md +556 -0
  1165. package/skills/creative/p5js/references/animation.md +439 -0
  1166. package/skills/creative/p5js/references/color-systems.md +352 -0
  1167. package/skills/creative/p5js/references/core-api.md +410 -0
  1168. package/skills/creative/p5js/references/export-pipeline.md +566 -0
  1169. package/skills/creative/p5js/references/interaction.md +398 -0
  1170. package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
  1171. package/skills/creative/p5js/references/troubleshooting.md +532 -0
  1172. package/skills/creative/p5js/references/typography.md +302 -0
  1173. package/skills/creative/p5js/references/visual-effects.md +895 -0
  1174. package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
  1175. package/skills/creative/p5js/scripts/export-frames.js +179 -0
  1176. package/skills/creative/p5js/scripts/render.sh +108 -0
  1177. package/skills/creative/p5js/scripts/serve.sh +28 -0
  1178. package/skills/creative/p5js/scripts/setup.sh +87 -0
  1179. package/skills/creative/p5js/templates/viewer.html +395 -0
  1180. package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
  1181. package/skills/creative/pixel-art/SKILL.md +218 -0
  1182. package/skills/creative/pixel-art/references/palettes.md +49 -0
  1183. package/skills/creative/pixel-art/scripts/__init__.py +0 -0
  1184. package/skills/creative/pixel-art/scripts/palettes.py +167 -0
  1185. package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
  1186. package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
  1187. package/skills/creative/popular-web-designs/SKILL.md +214 -0
  1188. package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
  1189. package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
  1190. package/skills/creative/popular-web-designs/templates/apple.md +326 -0
  1191. package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
  1192. package/skills/creative/popular-web-designs/templates/cal.md +272 -0
  1193. package/skills/creative/popular-web-designs/templates/claude.md +325 -0
  1194. package/skills/creative/popular-web-designs/templates/clay.md +317 -0
  1195. package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
  1196. package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
  1197. package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
  1198. package/skills/creative/popular-web-designs/templates/composio.md +320 -0
  1199. package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
  1200. package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
  1201. package/skills/creative/popular-web-designs/templates/expo.md +294 -0
  1202. package/skills/creative/popular-web-designs/templates/figma.md +233 -0
  1203. package/skills/creative/popular-web-designs/templates/framer.md +259 -0
  1204. package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
  1205. package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
  1206. package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
  1207. package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
  1208. package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
  1209. package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
  1210. package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
  1211. package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
  1212. package/skills/creative/popular-web-designs/templates/miro.md +121 -0
  1213. package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
  1214. package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
  1215. package/skills/creative/popular-web-designs/templates/notion.md +322 -0
  1216. package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
  1217. package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
  1218. package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
  1219. package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
  1220. package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
  1221. package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
  1222. package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
  1223. package/skills/creative/popular-web-designs/templates/resend.md +316 -0
  1224. package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
  1225. package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
  1226. package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
  1227. package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
  1228. package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
  1229. package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
  1230. package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
  1231. package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
  1232. package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
  1233. package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
  1234. package/skills/creative/popular-web-designs/templates/uber.md +308 -0
  1235. package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
  1236. package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
  1237. package/skills/creative/popular-web-designs/templates/warp.md +266 -0
  1238. package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
  1239. package/skills/creative/popular-web-designs/templates/wise.md +186 -0
  1240. package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
  1241. package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
  1242. package/skills/creative/pretext/SKILL.md +220 -0
  1243. package/skills/creative/pretext/references/patterns.md +258 -0
  1244. package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
  1245. package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
  1246. package/skills/creative/sketch/SKILL.md +218 -0
  1247. package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
  1248. package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
  1249. package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
  1250. package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
  1251. package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
  1252. package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
  1253. package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
  1254. package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
  1255. package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
  1256. package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
  1257. package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
  1258. package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
  1259. package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
  1260. package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
  1261. package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
  1262. package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
  1263. package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
  1264. package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
  1265. package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
  1266. package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
  1267. package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
  1268. package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
  1269. package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
  1270. package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
  1271. package/skills/data-science/DESCRIPTION.md +3 -0
  1272. package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
  1273. package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
  1274. package/skills/devops/kanban-worker/SKILL.md +184 -0
  1275. package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
  1276. package/skills/diagramming/DESCRIPTION.md +3 -0
  1277. package/skills/dogfood/SKILL.md +162 -0
  1278. package/skills/dogfood/references/issue-taxonomy.md +109 -0
  1279. package/skills/dogfood/templates/dogfood-report-template.md +86 -0
  1280. package/skills/domain/DESCRIPTION.md +24 -0
  1281. package/skills/email/DESCRIPTION.md +3 -0
  1282. package/skills/email/himalaya/SKILL.md +299 -0
  1283. package/skills/email/himalaya/references/configuration.md +227 -0
  1284. package/skills/email/himalaya/references/message-composition.md +199 -0
  1285. package/skills/gaming/DESCRIPTION.md +3 -0
  1286. package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
  1287. package/skills/gaming/pokemon-player/SKILL.md +216 -0
  1288. package/skills/gifs/DESCRIPTION.md +3 -0
  1289. package/skills/github/DESCRIPTION.md +3 -0
  1290. package/skills/github/codebase-inspection/SKILL.md +116 -0
  1291. package/skills/github/github-auth/SKILL.md +247 -0
  1292. package/skills/github/github-auth/scripts/gh-env.sh +66 -0
  1293. package/skills/github/github-code-review/SKILL.md +481 -0
  1294. package/skills/github/github-code-review/references/review-output-template.md +74 -0
  1295. package/skills/github/github-issues/SKILL.md +370 -0
  1296. package/skills/github/github-issues/templates/bug-report.md +35 -0
  1297. package/skills/github/github-issues/templates/feature-request.md +31 -0
  1298. package/skills/github/github-pr-workflow/SKILL.md +367 -0
  1299. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
  1300. package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
  1301. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
  1302. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
  1303. package/skills/github/github-repo-management/SKILL.md +516 -0
  1304. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
  1305. package/skills/index-cache/anthropics_skills_skills_.json +1 -0
  1306. package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
  1307. package/skills/index-cache/lobehub_index.json +1 -0
  1308. package/skills/index-cache/openai_skills_skills_.json +1 -0
  1309. package/skills/inference-sh/DESCRIPTION.md +19 -0
  1310. package/skills/mcp/DESCRIPTION.md +3 -0
  1311. package/skills/mcp/native-mcp/SKILL.md +357 -0
  1312. package/skills/media/DESCRIPTION.md +3 -0
  1313. package/skills/media/gif-search/SKILL.md +91 -0
  1314. package/skills/media/heartmula/SKILL.md +171 -0
  1315. package/skills/media/songsee/SKILL.md +83 -0
  1316. package/skills/media/spotify/SKILL.md +135 -0
  1317. package/skills/media/youtube-content/SKILL.md +73 -0
  1318. package/skills/media/youtube-content/references/output-formats.md +56 -0
  1319. package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
  1320. package/skills/mlops/DESCRIPTION.md +3 -0
  1321. package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
  1322. package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
  1323. package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
  1324. package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
  1325. package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
  1326. package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
  1327. package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
  1328. package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
  1329. package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
  1330. package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
  1331. package/skills/mlops/huggingface-hub/SKILL.md +81 -0
  1332. package/skills/mlops/inference/DESCRIPTION.md +3 -0
  1333. package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
  1334. package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
  1335. package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
  1336. package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
  1337. package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
  1338. package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
  1339. package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
  1340. package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
  1341. package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
  1342. package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
  1343. package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
  1344. package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
  1345. package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
  1346. package/skills/mlops/inference/vllm/SKILL.md +372 -0
  1347. package/skills/mlops/inference/vllm/references/optimization.md +226 -0
  1348. package/skills/mlops/inference/vllm/references/quantization.md +284 -0
  1349. package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
  1350. package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
  1351. package/skills/mlops/models/DESCRIPTION.md +3 -0
  1352. package/skills/mlops/models/audiocraft/SKILL.md +568 -0
  1353. package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
  1354. package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
  1355. package/skills/mlops/models/segment-anything/SKILL.md +506 -0
  1356. package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
  1357. package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
  1358. package/skills/mlops/research/DESCRIPTION.md +3 -0
  1359. package/skills/mlops/research/dspy/SKILL.md +594 -0
  1360. package/skills/mlops/research/dspy/references/examples.md +663 -0
  1361. package/skills/mlops/research/dspy/references/modules.md +475 -0
  1362. package/skills/mlops/research/dspy/references/optimizers.md +566 -0
  1363. package/skills/mlops/training/DESCRIPTION.md +3 -0
  1364. package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
  1365. package/skills/note-taking/DESCRIPTION.md +3 -0
  1366. package/skills/note-taking/obsidian/SKILL.md +61 -0
  1367. package/skills/productivity/DESCRIPTION.md +3 -0
  1368. package/skills/productivity/airtable/SKILL.md +229 -0
  1369. package/skills/productivity/google-workspace/SKILL.md +335 -0
  1370. package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
  1371. package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
  1372. package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
  1373. package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
  1374. package/skills/productivity/google-workspace/scripts/setup.py +454 -0
  1375. package/skills/productivity/linear/SKILL.md +380 -0
  1376. package/skills/productivity/linear/scripts/linear_api.py +445 -0
  1377. package/skills/productivity/maps/SKILL.md +195 -0
  1378. package/skills/productivity/maps/scripts/maps_client.py +1298 -0
  1379. package/skills/productivity/nano-pdf/SKILL.md +52 -0
  1380. package/skills/productivity/notion/SKILL.md +448 -0
  1381. package/skills/productivity/notion/references/block-types.md +112 -0
  1382. package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
  1383. package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
  1384. package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
  1385. package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
  1386. package/skills/productivity/powerpoint/LICENSE.txt +30 -0
  1387. package/skills/productivity/powerpoint/SKILL.md +237 -0
  1388. package/skills/productivity/powerpoint/editing.md +205 -0
  1389. package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
  1390. package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
  1391. package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
  1392. package/skills/productivity/powerpoint/scripts/clean.py +286 -0
  1393. package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
  1394. package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
  1395. package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
  1396. package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
  1397. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  1398. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  1399. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  1400. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  1401. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  1402. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  1403. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  1404. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  1405. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  1406. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  1407. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  1408. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  1409. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  1410. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  1411. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  1412. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  1413. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  1414. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  1415. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  1416. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  1417. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  1418. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  1419. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  1420. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  1421. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  1422. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  1423. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  1424. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
  1425. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
  1426. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
  1427. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
  1428. package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
  1429. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  1430. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  1431. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  1432. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  1433. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  1434. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  1435. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  1436. package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
  1437. package/skills/red-teaming/godmode/SKILL.md +404 -0
  1438. package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
  1439. package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
  1440. package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
  1441. package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
  1442. package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
  1443. package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
  1444. package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
  1445. package/skills/red-teaming/godmode/templates/prefill.json +18 -0
  1446. package/skills/research/DESCRIPTION.md +3 -0
  1447. package/skills/research/arxiv/SKILL.md +282 -0
  1448. package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
  1449. package/skills/research/blogwatcher/SKILL.md +137 -0
  1450. package/skills/research/llm-wiki/SKILL.md +507 -0
  1451. package/skills/research/polymarket/SKILL.md +77 -0
  1452. package/skills/research/polymarket/references/api-endpoints.md +220 -0
  1453. package/skills/research/polymarket/scripts/polymarket.py +284 -0
  1454. package/skills/research/research-paper-writing/SKILL.md +2377 -0
  1455. package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
  1456. package/skills/research/research-paper-writing/references/checklists.md +434 -0
  1457. package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
  1458. package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
  1459. package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
  1460. package/skills/research/research-paper-writing/references/paper-types.md +481 -0
  1461. package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
  1462. package/skills/research/research-paper-writing/references/sources.md +191 -0
  1463. package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
  1464. package/skills/research/research-paper-writing/templates/README.md +251 -0
  1465. package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
  1466. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  1467. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  1468. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
  1469. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
  1470. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
  1471. package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
  1472. package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
  1473. package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
  1474. package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
  1475. package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
  1476. package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
  1477. package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
  1478. package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
  1479. package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
  1480. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
  1481. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
  1482. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
  1483. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
  1484. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
  1485. package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
  1486. package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
  1487. package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
  1488. package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
  1489. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
  1490. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
  1491. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
  1492. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
  1493. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
  1494. package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
  1495. package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
  1496. package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
  1497. package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
  1498. package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
  1499. package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
  1500. package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
  1501. package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
  1502. package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
  1503. package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
  1504. package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
  1505. package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
  1506. package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
  1507. package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
  1508. package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
  1509. package/skills/smart-home/DESCRIPTION.md +3 -0
  1510. package/skills/smart-home/openhue/SKILL.md +109 -0
  1511. package/skills/social-media/DESCRIPTION.md +3 -0
  1512. package/skills/social-media/xurl/SKILL.md +414 -0
  1513. package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
  1514. package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
  1515. package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
  1516. package/skills/software-development/plan/SKILL.md +58 -0
  1517. package/skills/software-development/python-debugpy/SKILL.md +375 -0
  1518. package/skills/software-development/requesting-code-review/SKILL.md +280 -0
  1519. package/skills/software-development/spike/SKILL.md +197 -0
  1520. package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
  1521. package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
  1522. package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
  1523. package/skills/software-development/systematic-debugging/SKILL.md +367 -0
  1524. package/skills/software-development/test-driven-development/SKILL.md +343 -0
  1525. package/skills/software-development/writing-plans/SKILL.md +297 -0
  1526. package/skills/yuanbao/SKILL.md +108 -0
  1527. package/tools/__init__.py +25 -0
  1528. package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  1529. package/tools/__pycache__/approval.cpython-312.pyc +0 -0
  1530. package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
  1531. package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
  1532. package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
  1533. package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
  1534. package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
  1535. package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
  1536. package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
  1537. package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
  1538. package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
  1539. package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
  1540. package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
  1541. package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
  1542. package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
  1543. package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
  1544. package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
  1545. package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
  1546. package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
  1547. package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
  1548. package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
  1549. package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
  1550. package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
  1551. package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  1552. package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
  1553. package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
  1554. package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
  1555. package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
  1556. package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
  1557. package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
  1558. package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
  1559. package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
  1560. package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
  1561. package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
  1562. package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
  1563. package/tools/__pycache__/registry.cpython-312.pyc +0 -0
  1564. package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
  1565. package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
  1566. package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
  1567. package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
  1568. package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
  1569. package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
  1570. package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
  1571. package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
  1572. package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
  1573. package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
  1574. package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
  1575. package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
  1576. package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
  1577. package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
  1578. package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
  1579. package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
  1580. package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
  1581. package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
  1582. package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
  1583. package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
  1584. package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  1585. package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
  1586. package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
  1587. package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
  1588. package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
  1589. package/tools/ansi_strip.py +44 -0
  1590. package/tools/approval.py +1392 -0
  1591. package/tools/binary_extensions.py +42 -0
  1592. package/tools/browser_camofox.py +700 -0
  1593. package/tools/browser_camofox_state.py +48 -0
  1594. package/tools/browser_cdp_tool.py +569 -0
  1595. package/tools/browser_dialog_tool.py +148 -0
  1596. package/tools/browser_providers/__init__.py +10 -0
  1597. package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
  1598. package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
  1599. package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
  1600. package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
  1601. package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
  1602. package/tools/browser_providers/base.py +59 -0
  1603. package/tools/browser_providers/browser_use.py +225 -0
  1604. package/tools/browser_providers/browserbase.py +222 -0
  1605. package/tools/browser_providers/firecrawl.py +112 -0
  1606. package/tools/browser_supervisor.py +1457 -0
  1607. package/tools/browser_tool.py +3676 -0
  1608. package/tools/budget_config.py +51 -0
  1609. package/tools/checkpoint_manager.py +1639 -0
  1610. package/tools/clarify_gateway.py +278 -0
  1611. package/tools/clarify_tool.py +141 -0
  1612. package/tools/code_execution_tool.py +1782 -0
  1613. package/tools/computer_use/__init__.py +43 -0
  1614. package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
  1615. package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
  1616. package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
  1617. package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
  1618. package/tools/computer_use/backend.py +150 -0
  1619. package/tools/computer_use/cua_backend.py +682 -0
  1620. package/tools/computer_use/schema.py +191 -0
  1621. package/tools/computer_use/tool.py +521 -0
  1622. package/tools/computer_use_tool.py +39 -0
  1623. package/tools/credential_files.py +437 -0
  1624. package/tools/cronjob_tools.py +719 -0
  1625. package/tools/debug_helpers.py +106 -0
  1626. package/tools/delegate_tool.py +2797 -0
  1627. package/tools/discord_tool.py +959 -0
  1628. package/tools/env_passthrough.py +145 -0
  1629. package/tools/environments/__init__.py +14 -0
  1630. package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
  1631. package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
  1632. package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
  1633. package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
  1634. package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
  1635. package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
  1636. package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
  1637. package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
  1638. package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
  1639. package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
  1640. package/tools/environments/base.py +844 -0
  1641. package/tools/environments/daytona.py +270 -0
  1642. package/tools/environments/docker.py +656 -0
  1643. package/tools/environments/file_sync.py +400 -0
  1644. package/tools/environments/local.py +658 -0
  1645. package/tools/environments/managed_modal.py +282 -0
  1646. package/tools/environments/modal.py +479 -0
  1647. package/tools/environments/modal_utils.py +199 -0
  1648. package/tools/environments/singularity.py +263 -0
  1649. package/tools/environments/ssh.py +295 -0
  1650. package/tools/environments/vercel_sandbox.py +655 -0
  1651. package/tools/feishu_doc_tool.py +138 -0
  1652. package/tools/feishu_drive_tool.py +431 -0
  1653. package/tools/file_operations.py +1825 -0
  1654. package/tools/file_state.py +332 -0
  1655. package/tools/file_tools.py +1172 -0
  1656. package/tools/fuzzy_match.py +703 -0
  1657. package/tools/homeassistant_tool.py +513 -0
  1658. package/tools/image_generation_tool.py +1098 -0
  1659. package/tools/interrupt.py +98 -0
  1660. package/tools/kanban_tools.py +1139 -0
  1661. package/tools/lazy_deps.py +608 -0
  1662. package/tools/managed_tool_gateway.py +168 -0
  1663. package/tools/mcp_oauth.py +633 -0
  1664. package/tools/mcp_oauth_manager.py +607 -0
  1665. package/tools/mcp_tool.py +3483 -0
  1666. package/tools/memory_tool.py +584 -0
  1667. package/tools/microsoft_graph_auth.py +245 -0
  1668. package/tools/microsoft_graph_client.py +408 -0
  1669. package/tools/mixture_of_agents_tool.py +542 -0
  1670. package/tools/neutts_samples/jo.txt +1 -0
  1671. package/tools/neutts_samples/jo.wav +0 -0
  1672. package/tools/neutts_synth.py +104 -0
  1673. package/tools/openrouter_client.py +33 -0
  1674. package/tools/osv_check.py +155 -0
  1675. package/tools/patch_parser.py +592 -0
  1676. package/tools/path_security.py +43 -0
  1677. package/tools/process_registry.py +1534 -0
  1678. package/tools/registry.py +589 -0
  1679. package/tools/schema_sanitizer.py +370 -0
  1680. package/tools/send_message_tool.py +1900 -0
  1681. package/tools/session_search_tool.py +613 -0
  1682. package/tools/skill_manager_tool.py +932 -0
  1683. package/tools/skill_provenance.py +78 -0
  1684. package/tools/skill_usage.py +610 -0
  1685. package/tools/skills_guard.py +932 -0
  1686. package/tools/skills_hub.py +3263 -0
  1687. package/tools/skills_sync.py +432 -0
  1688. package/tools/skills_tool.py +1569 -0
  1689. package/tools/slash_confirm.py +167 -0
  1690. package/tools/terminal_tool.py +2376 -0
  1691. package/tools/tirith_security.py +775 -0
  1692. package/tools/todo_tool.py +277 -0
  1693. package/tools/tool_backend_helpers.py +144 -0
  1694. package/tools/tool_output_limits.py +92 -0
  1695. package/tools/tool_result_storage.py +232 -0
  1696. package/tools/transcription_tools.py +936 -0
  1697. package/tools/tts_tool.py +2285 -0
  1698. package/tools/url_safety.py +330 -0
  1699. package/tools/video_generation_tool.py +561 -0
  1700. package/tools/vision_tools.py +1422 -0
  1701. package/tools/voice_mode.py +1019 -0
  1702. package/tools/web_tools.py +1551 -0
  1703. package/tools/website_policy.py +283 -0
  1704. package/tools/x_search_tool.py +424 -0
  1705. package/tools/xai_http.py +83 -0
  1706. package/tools/yuanbao_tools.py +736 -0
  1707. package/toolset_distributions.py +364 -0
  1708. package/toolsets.py +866 -0
  1709. package/trajectory_compressor.py +1509 -0
  1710. package/tui_gateway/__init__.py +0 -0
  1711. package/tui_gateway/entry.py +251 -0
  1712. package/tui_gateway/event_publisher.py +126 -0
  1713. package/tui_gateway/render.py +49 -0
  1714. package/tui_gateway/server.py +6623 -0
  1715. package/tui_gateway/slash_worker.py +76 -0
  1716. package/tui_gateway/transport.py +219 -0
  1717. package/tui_gateway/ws.py +178 -0
  1718. package/utils.py +361 -0
@@ -0,0 +1,3747 @@
1
+ """
2
+ Base platform adapter interface.
3
+
4
+ All platform adapters (Telegram, Discord, WhatsApp, Weixin, and more) inherit from this
5
+ and implement the required methods.
6
+ """
7
+
8
+ import asyncio
9
+ import inspect
10
+ import ipaddress
11
+ import logging
12
+ import os
13
+ import random
14
+ import re
15
+ import socket as _socket
16
+ import subprocess
17
+ import sys
18
+ import uuid
19
+ from abc import ABC, abstractmethod
20
+ from urllib.parse import urlsplit
21
+
22
+ from utils import normalize_proxy_url
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Audio file extensions Hermes recognizes for native audio delivery.
27
+ # Kept in sync with tools/send_message_tool.py and cron/scheduler.py via
28
+ # should_send_media_as_audio() below.
29
+ _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
30
+ # Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
31
+ # formats either need to go through sendVoice (Opus/OGG) or must be
32
+ # delivered as a regular document.
33
+ _TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
34
+ _TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
35
+
36
+
37
+ def _platform_name(platform) -> str:
38
+ """Normalize a Platform enum / raw string into a lowercase name."""
39
+ value = getattr(platform, "value", platform)
40
+ return str(value or "").lower()
41
+
42
+
43
+ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) -> dict | None:
44
+ """Build platform-aware thread metadata for adapter sends.
45
+
46
+ Most platforms route threaded sends with a generic ``thread_id`` metadata
47
+ value. Telegram private-chat topics created through Hermes' DM-topic helper
48
+ are exposed in updates as ``message_thread_id`` plus a reply anchor, but
49
+ outbound sends only render in the correct Telegram lane when the adapter
50
+ supplies both ``message_thread_id`` and ``reply_to_message_id``. Mark those
51
+ lanes so the Telegram adapter can avoid the known-bad partial routes.
52
+ """
53
+ thread_id = getattr(source, "thread_id", None)
54
+ if thread_id is None:
55
+ return None
56
+ metadata = {"thread_id": thread_id}
57
+ if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm":
58
+ metadata["telegram_dm_topic_reply_fallback"] = True
59
+ anchor = reply_to_message_id or getattr(source, "message_id", None)
60
+ if anchor is not None:
61
+ metadata["telegram_reply_to_message_id"] = str(anchor)
62
+ return metadata
63
+
64
+
65
+ def _reply_anchor_for_event(event) -> str | None:
66
+ """Return reply_to id for platforms that need reply semantics.
67
+
68
+ Telegram forum/supergroup topics should be routed by topic metadata, not by
69
+ replying to the triggering message. Hermes-created Telegram private-chat
70
+ topic lanes are different: Bot API sends reject their ``message_thread_id``
71
+ and do not route with ``direct_messages_topic_id``. Those lanes only remain
72
+ visible when sent with both the private topic thread id and a reply to the
73
+ triggering user message.
74
+ """
75
+ source = getattr(event, "source", None)
76
+ platform = _platform_name(getattr(source, "platform", None))
77
+ thread_id = getattr(source, "thread_id", None)
78
+ if platform == "telegram" and thread_id and getattr(source, "chat_type", None) == "dm":
79
+ # Reply to the triggering user message. Replying to Telegram's earlier
80
+ # topic seed/anchor can render the bot response outside the active lane.
81
+ return getattr(event, "message_id", None) or getattr(event, "reply_to_message_id", None)
82
+ if platform == "telegram" and thread_id:
83
+ return None
84
+ if platform == "feishu" and thread_id and getattr(event, "reply_to_message_id", None):
85
+ return getattr(event, "reply_to_message_id", None)
86
+ return getattr(event, "message_id", None)
87
+
88
+
89
+ def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:
90
+ """Return True when a media file should use the platform's audio sender.
91
+
92
+ Other platforms: every recognized audio extension routes through the
93
+ audio sender.
94
+
95
+ Telegram: the Bot API only accepts MP3/M4A for sendAudio and
96
+ Opus/OGG for sendVoice. Opus/OGG is only routed as audio when the
97
+ caller flagged ``is_voice=True`` (so we don't turn a regular audio
98
+ attachment into a voice bubble just because the file happens to be
99
+ Opus). Everything else falls through to document delivery by
100
+ returning ``False``.
101
+ """
102
+ normalized_ext = (ext or "").lower()
103
+ if normalized_ext not in _AUDIO_EXTS:
104
+ return False
105
+ if _platform_name(platform) == "telegram":
106
+ if normalized_ext in _TELEGRAM_VOICE_EXTS:
107
+ return is_voice
108
+ return normalized_ext in _TELEGRAM_AUDIO_ATTACHMENT_EXTS
109
+ return True
110
+
111
+
112
+ def utf16_len(s: str) -> int:
113
+ """Count UTF-16 code units in *s*.
114
+
115
+ Telegram's message-length limit (4 096) is measured in UTF-16 code units,
116
+ **not** Unicode code-points. Characters outside the Basic Multilingual
117
+ Plane (emoji like 😀, CJK Extension B, musical symbols, …) are encoded as
118
+ surrogate pairs and therefore consume **two** UTF-16 code units each, even
119
+ though Python's ``len()`` counts them as one.
120
+
121
+ Ported from nearai/ironclaw#2304 which discovered the same discrepancy in
122
+ Rust's ``chars().count()``.
123
+ """
124
+ return len(s.encode("utf-16-le")) // 2
125
+
126
+
127
+ def _prefix_within_utf16_limit(s: str, limit: int) -> str:
128
+ """Return the longest prefix of *s* whose UTF-16 length ≤ *limit*.
129
+
130
+ Unlike a plain ``s[:limit]``, this respects surrogate-pair boundaries so
131
+ we never slice a multi-code-unit character in half.
132
+ """
133
+ if utf16_len(s) <= limit:
134
+ return s
135
+ # Binary search for the longest safe prefix
136
+ lo, hi = 0, len(s)
137
+ while lo < hi:
138
+ mid = (lo + hi + 1) // 2
139
+ if utf16_len(s[:mid]) <= limit:
140
+ lo = mid
141
+ else:
142
+ hi = mid - 1
143
+ return s[:lo]
144
+
145
+
146
+ def _custom_unit_to_cp(s: str, budget: int, len_fn) -> int:
147
+ """Return the largest codepoint offset *n* such that ``len_fn(s[:n]) <= budget``.
148
+
149
+ Used by :meth:`BasePlatformAdapter.truncate_message` when *len_fn* measures
150
+ length in units different from Python codepoints (e.g. UTF-16 code units).
151
+ Falls back to binary search which is O(log n) calls to *len_fn*.
152
+ """
153
+ if len_fn(s) <= budget:
154
+ return len(s)
155
+ lo, hi = 0, len(s)
156
+ while lo < hi:
157
+ mid = (lo + hi + 1) // 2
158
+ if len_fn(s[:mid]) <= budget:
159
+ lo = mid
160
+ else:
161
+ hi = mid - 1
162
+ return lo
163
+
164
+
165
+ def is_network_accessible(host: str) -> bool:
166
+ """Return True if *host* would expose the server beyond loopback.
167
+
168
+ Loopback addresses (127.0.0.1, ::1, IPv4-mapped ::ffff:127.0.0.1)
169
+ are local-only. Unspecified addresses (0.0.0.0, ::) bind all
170
+ interfaces. Hostnames are resolved; DNS failure fails closed.
171
+ """
172
+ try:
173
+ addr = ipaddress.ip_address(host)
174
+ if addr.is_loopback:
175
+ return False
176
+ # ::ffff:127.0.0.1 — Python reports is_loopback=False for mapped
177
+ # addresses, so check the underlying IPv4 explicitly.
178
+ if getattr(addr, "ipv4_mapped", None) and addr.ipv4_mapped.is_loopback:
179
+ return False
180
+ return True
181
+ except ValueError:
182
+ # when host variable is a hostname, we should try to resolve below
183
+ pass
184
+
185
+ try:
186
+ resolved = _socket.getaddrinfo(
187
+ host, None, _socket.AF_UNSPEC, _socket.SOCK_STREAM,
188
+ )
189
+ # if the hostname resolves into at least one non-loopback address,
190
+ # then we consider it to be network accessible
191
+ for _family, _type, _proto, _canonname, sockaddr in resolved:
192
+ addr = ipaddress.ip_address(sockaddr[0])
193
+ if not addr.is_loopback:
194
+ return True
195
+ return False
196
+ except (_socket.gaierror, OSError):
197
+ return True
198
+
199
+
200
+ def _detect_macos_system_proxy() -> str | None:
201
+ """Read the macOS system HTTP(S) proxy via ``scutil --proxy``.
202
+
203
+ Returns an ``http://host:port`` URL string if an HTTP or HTTPS proxy is
204
+ enabled, otherwise *None*. Falls back silently on non-macOS or on any
205
+ subprocess error.
206
+ """
207
+ if sys.platform != "darwin":
208
+ return None
209
+ try:
210
+ out = subprocess.check_output(
211
+ ["scutil", "--proxy"], timeout=3, text=True, stderr=subprocess.DEVNULL,
212
+ )
213
+ except Exception:
214
+ return None
215
+
216
+ props: dict[str, str] = {}
217
+ for line in out.splitlines():
218
+ line = line.strip()
219
+ if " : " in line:
220
+ key, _, val = line.partition(" : ")
221
+ props[key.strip()] = val.strip()
222
+
223
+ # Prefer HTTPS, fall back to HTTP
224
+ for enable_key, host_key, port_key in (
225
+ ("HTTPSEnable", "HTTPSProxy", "HTTPSPort"),
226
+ ("HTTPEnable", "HTTPProxy", "HTTPPort"),
227
+ ):
228
+ if props.get(enable_key) == "1":
229
+ host = props.get(host_key)
230
+ port = props.get(port_key)
231
+ if host and port:
232
+ return f"http://{host}:{port}"
233
+ return None
234
+
235
+
236
+ def _split_host_port(value: str) -> tuple[str, int | None]:
237
+ raw = str(value or "").strip()
238
+ if not raw:
239
+ return "", None
240
+ if "://" in raw:
241
+ parsed = urlsplit(raw)
242
+ return (parsed.hostname or "").lower().rstrip("."), parsed.port
243
+ if raw.startswith("[") and "]" in raw:
244
+ host, _, rest = raw[1:].partition("]")
245
+ port = None
246
+ if rest.startswith(":") and rest[1:].isdigit():
247
+ port = int(rest[1:])
248
+ return host.lower().rstrip("."), port
249
+ if raw.count(":") == 1:
250
+ host, _, maybe_port = raw.rpartition(":")
251
+ if maybe_port.isdigit():
252
+ return host.lower().rstrip("."), int(maybe_port)
253
+ return raw.lower().strip("[]").rstrip("."), None
254
+
255
+
256
+ def _no_proxy_entries() -> list[str]:
257
+ entries: list[str] = []
258
+ for key in ("NO_PROXY", "no_proxy"):
259
+ raw = os.environ.get(key, "")
260
+ entries.extend(part.strip() for part in raw.split(",") if part.strip())
261
+ return entries
262
+
263
+
264
+ def _no_proxy_entry_matches(entry: str, host: str, port: int | None = None) -> bool:
265
+ token = str(entry or "").strip().lower()
266
+ if not token:
267
+ return False
268
+ if token == "*":
269
+ return True
270
+
271
+ token_host, token_port = _split_host_port(token)
272
+ if token_port is not None and port is not None and token_port != port:
273
+ return False
274
+ if token_port is not None and port is None:
275
+ return False
276
+ if not token_host:
277
+ return False
278
+
279
+ try:
280
+ network = ipaddress.ip_network(token_host, strict=False)
281
+ try:
282
+ return ipaddress.ip_address(host) in network
283
+ except ValueError:
284
+ return False
285
+ except ValueError:
286
+ pass
287
+
288
+ try:
289
+ token_ip = ipaddress.ip_address(token_host)
290
+ try:
291
+ return ipaddress.ip_address(host) == token_ip
292
+ except ValueError:
293
+ return False
294
+ except ValueError:
295
+ pass
296
+
297
+ if token_host.startswith("*."):
298
+ suffix = token_host[1:]
299
+ return host.endswith(suffix)
300
+ if token_host.startswith("."):
301
+ return host == token_host[1:] or host.endswith(token_host)
302
+ return host == token_host or host.endswith(f".{token_host}")
303
+
304
+
305
+ def should_bypass_proxy(target_hosts: str | list[str] | tuple[str, ...] | set[str] | None) -> bool:
306
+ """Return True when NO_PROXY/no_proxy matches at least one target host.
307
+
308
+ Supports exact hosts, domain suffixes, wildcard suffixes, IP literals,
309
+ CIDR ranges, optional host:port entries, and ``*``.
310
+ """
311
+ entries = _no_proxy_entries()
312
+ if not entries or not target_hosts:
313
+ return False
314
+ if isinstance(target_hosts, str):
315
+ candidates = [target_hosts]
316
+ else:
317
+ candidates = list(target_hosts)
318
+ for candidate in candidates:
319
+ host, port = _split_host_port(str(candidate))
320
+ if not host:
321
+ continue
322
+ if any(_no_proxy_entry_matches(entry, host, port) for entry in entries):
323
+ return True
324
+ return False
325
+
326
+
327
+ def resolve_proxy_url(
328
+ platform_env_var: str | None = None,
329
+ *,
330
+ target_hosts: str | list[str] | tuple[str, ...] | set[str] | None = None,
331
+ ) -> str | None:
332
+ """Return a proxy URL from env vars, or macOS system proxy.
333
+
334
+ Check order:
335
+ 0. *platform_env_var* (e.g. ``DISCORD_PROXY``) — highest priority
336
+ 1. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants)
337
+ 2. macOS system proxy via ``scutil --proxy`` (auto-detect)
338
+
339
+ Returns *None* if no proxy is found, or if NO_PROXY/no_proxy matches one
340
+ of ``target_hosts``.
341
+ """
342
+ if platform_env_var:
343
+ value = (os.environ.get(platform_env_var) or "").strip()
344
+ if value:
345
+ if should_bypass_proxy(target_hosts):
346
+ return None
347
+ return normalize_proxy_url(value)
348
+ for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
349
+ "https_proxy", "http_proxy", "all_proxy"):
350
+ value = (os.environ.get(key) or "").strip()
351
+ if value:
352
+ if should_bypass_proxy(target_hosts):
353
+ return None
354
+ return normalize_proxy_url(value)
355
+ detected = normalize_proxy_url(_detect_macos_system_proxy())
356
+ if detected and should_bypass_proxy(target_hosts):
357
+ return None
358
+ return detected
359
+
360
+
361
+ def proxy_kwargs_for_bot(proxy_url: str | None) -> dict:
362
+ """Build kwargs for ``commands.Bot()`` / ``discord.Client()`` with proxy.
363
+
364
+ Returns:
365
+ - SOCKS URL → ``{"connector": ProxyConnector(..., rdns=True)}``
366
+ - HTTP URL → ``{"proxy": url}``
367
+ - *None* → ``{}``
368
+
369
+ ``rdns=True`` forces remote DNS resolution through the proxy — required
370
+ by many SOCKS implementations (Shadowrocket, Clash) and essential for
371
+ bypassing DNS pollution behind the GFW.
372
+ """
373
+ if not proxy_url:
374
+ return {}
375
+ if proxy_url.lower().startswith("socks"):
376
+ try:
377
+ from aiohttp_socks import ProxyConnector
378
+
379
+ connector = ProxyConnector.from_url(proxy_url, rdns=True)
380
+ return {"connector": connector}
381
+ except ImportError:
382
+ logger.warning(
383
+ "aiohttp_socks not installed — SOCKS proxy %s ignored. "
384
+ "Run: pip install aiohttp-socks",
385
+ proxy_url,
386
+ )
387
+ return {}
388
+ return {"proxy": proxy_url}
389
+
390
+
391
+ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
392
+ """Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
393
+
394
+ Returns ``(session_kwargs, request_kwargs)`` where:
395
+ - With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})``
396
+ for *all* proxy schemes (SOCKS **and** HTTP/HTTPS).
397
+ - HTTP without aiohttp-socks → ``({}, {"proxy": url})``.
398
+ - None → ``({}, {})``.
399
+
400
+ Prefer the connector path: it works transparently with libraries
401
+ (like mautrix) that call ``session.request()`` without forwarding
402
+ per-request ``proxy=`` kwargs.
403
+
404
+ Usage::
405
+
406
+ sess_kw, req_kw = proxy_kwargs_for_aiohttp(proxy_url)
407
+ async with aiohttp.ClientSession(**sess_kw) as session:
408
+ async with session.get(url, **req_kw) as resp:
409
+ ...
410
+ """
411
+ if not proxy_url:
412
+ return {}, {}
413
+ try:
414
+ from aiohttp_socks import ProxyConnector
415
+
416
+ connector = ProxyConnector.from_url(proxy_url, rdns=True)
417
+ return {"connector": connector}, {}
418
+ except ImportError:
419
+ if proxy_url.lower().startswith("socks"):
420
+ logger.warning(
421
+ "aiohttp_socks not installed — SOCKS proxy %s ignored. "
422
+ "Run: pip install aiohttp-socks",
423
+ proxy_url,
424
+ )
425
+ return {}, {}
426
+ return {}, {"proxy": proxy_url}
427
+
428
+
429
+ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
430
+ """Return True when ``hostname`` matches a ``NO_PROXY`` entry.
431
+
432
+ Supports comma- or whitespace-separated entries with optional leading dots
433
+ and ``*.`` wildcards, which match both the apex domain and subdomains.
434
+ """
435
+ raw = no_proxy_value
436
+ if raw is None:
437
+ raw = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or ""
438
+
439
+ raw = raw.strip()
440
+ if not raw:
441
+ return False
442
+
443
+ lower_hostname = hostname.lower()
444
+ for entry in re.split(r"[\s,]+", raw):
445
+ normalized = entry.strip().lower()
446
+ if not normalized:
447
+ continue
448
+ if normalized == "*":
449
+ return True
450
+
451
+ if normalized.startswith("*."):
452
+ normalized = normalized[2:]
453
+ elif normalized.startswith("."):
454
+ normalized = normalized[1:]
455
+
456
+ if lower_hostname == normalized or lower_hostname.endswith(f".{normalized}"):
457
+ return True
458
+
459
+ return False
460
+
461
+
462
+ from dataclasses import dataclass, field
463
+ from datetime import datetime
464
+ from pathlib import Path
465
+ from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union
466
+ from enum import Enum
467
+
468
+ from pathlib import Path as _Path
469
+ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
470
+
471
+ from gateway.config import Platform, PlatformConfig
472
+ from gateway.session import SessionSource, build_session_key
473
+ from calvyn_constants import get_hermes_dir
474
+
475
+
476
+ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
477
+ "Secure secret entry is not supported over messaging. "
478
+ "Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually."
479
+ )
480
+
481
+
482
+ def safe_url_for_log(url: str, max_len: int = 80) -> str:
483
+ """Return a URL string safe for logs (no query/fragment/userinfo)."""
484
+ if max_len <= 0:
485
+ return ""
486
+
487
+ if url is None:
488
+ return ""
489
+
490
+ raw = str(url)
491
+ if not raw:
492
+ return ""
493
+
494
+ try:
495
+ parsed = urlsplit(raw)
496
+ except Exception:
497
+ return raw[:max_len]
498
+
499
+ if parsed.scheme and parsed.netloc:
500
+ # Strip potential embedded credentials (user:pass@host).
501
+ netloc = parsed.netloc.rsplit("@", 1)[-1]
502
+ base = f"{parsed.scheme}://{netloc}"
503
+ path = parsed.path or ""
504
+ if path and path != "/":
505
+ basename = path.rsplit("/", 1)[-1]
506
+ safe = f"{base}/.../{basename}" if basename else f"{base}/..."
507
+ else:
508
+ safe = base
509
+ else:
510
+ safe = raw
511
+
512
+ if len(safe) <= max_len:
513
+ return safe
514
+ if max_len <= 3:
515
+ return "." * max_len
516
+ return f"{safe[:max_len - 3]}..."
517
+
518
+
519
+ async def _ssrf_redirect_guard(response):
520
+ """Re-validate each redirect target to prevent redirect-based SSRF.
521
+
522
+ Without this, an attacker can host a public URL that 302-redirects to
523
+ http://169.254.169.254/ and bypass the pre-flight is_safe_url() check.
524
+
525
+ Must be async because httpx.AsyncClient awaits response event hooks.
526
+ """
527
+ if response.is_redirect and response.next_request:
528
+ redirect_url = str(response.next_request.url)
529
+ from tools.url_safety import is_safe_url
530
+ if not is_safe_url(redirect_url):
531
+ raise ValueError(
532
+ f"Blocked redirect to private/internal address: {safe_url_for_log(redirect_url)}"
533
+ )
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Image cache utilities
538
+ #
539
+ # When users send images on messaging platforms, we download them to a local
540
+ # cache directory so they can be analyzed by the vision tool (which accepts
541
+ # local file paths). This avoids issues with ephemeral platform URLs
542
+ # (e.g. Telegram file URLs expire after ~1 hour).
543
+ # ---------------------------------------------------------------------------
544
+
545
+ # Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/)
546
+ IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache")
547
+
548
+
549
+ def get_image_cache_dir() -> Path:
550
+ """Return the image cache directory, creating it if it doesn't exist."""
551
+ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
552
+ return IMAGE_CACHE_DIR
553
+
554
+
555
+ def _looks_like_image(data: bytes) -> bool:
556
+ """Return True if *data* starts with a known image magic-byte sequence."""
557
+ if len(data) < 4:
558
+ return False
559
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
560
+ return True
561
+ if data[:3] == b"\xff\xd8\xff":
562
+ return True
563
+ if data[:6] in {b"GIF87a", b"GIF89a"}:
564
+ return True
565
+ if data[:2] == b"BM":
566
+ return True
567
+ if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP":
568
+ return True
569
+ return False
570
+
571
+
572
+ def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
573
+ """
574
+ Save raw image bytes to the cache and return the absolute file path.
575
+
576
+ Args:
577
+ data: Raw image bytes.
578
+ ext: File extension including the dot (e.g. ".jpg", ".png").
579
+
580
+ Returns:
581
+ Absolute path to the cached image file as a string.
582
+
583
+ Raises:
584
+ ValueError: If *data* does not look like a valid image (e.g. an HTML
585
+ error page returned by the upstream server).
586
+ """
587
+ if not _looks_like_image(data):
588
+ snippet = data[:80].decode("utf-8", errors="replace")
589
+ raise ValueError(
590
+ f"Refusing to cache non-image data as {ext} "
591
+ f"(starts with: {snippet!r})"
592
+ )
593
+ cache_dir = get_image_cache_dir()
594
+ filename = f"img_{uuid.uuid4().hex[:12]}{ext}"
595
+ filepath = cache_dir / filename
596
+ filepath.write_bytes(data)
597
+ return str(filepath)
598
+
599
+
600
+ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) -> str:
601
+ """
602
+ Download an image from a URL and save it to the local cache.
603
+
604
+ Retries on transient failures (timeouts, 429, 5xx) with exponential
605
+ backoff so a single slow CDN response doesn't lose the media.
606
+
607
+ Args:
608
+ url: The HTTP/HTTPS URL to download from.
609
+ ext: File extension including the dot (e.g. ".jpg", ".png").
610
+ retries: Number of retry attempts on transient failures.
611
+
612
+ Returns:
613
+ Absolute path to the cached image file as a string.
614
+
615
+ Raises:
616
+ ValueError: If the URL targets a private/internal network (SSRF protection).
617
+ """
618
+ from tools.url_safety import is_safe_url
619
+ if not is_safe_url(url):
620
+ raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}")
621
+
622
+ import httpx
623
+ _log = logging.getLogger(__name__)
624
+
625
+ async with httpx.AsyncClient(
626
+ timeout=30.0,
627
+ follow_redirects=True,
628
+ event_hooks={"response": [_ssrf_redirect_guard]},
629
+ ) as client:
630
+ for attempt in range(retries + 1):
631
+ try:
632
+ response = await client.get(
633
+ url,
634
+ headers={
635
+ "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
636
+ "Accept": "image/*,*/*;q=0.8",
637
+ },
638
+ )
639
+ response.raise_for_status()
640
+ return cache_image_from_bytes(response.content, ext)
641
+ except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
642
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
643
+ raise
644
+ if attempt < retries:
645
+ wait = 1.5 * (attempt + 1)
646
+ _log.debug(
647
+ "Media cache retry %d/%d for %s (%.1fs): %s",
648
+ attempt + 1,
649
+ retries,
650
+ safe_url_for_log(url),
651
+ wait,
652
+ exc,
653
+ )
654
+ await asyncio.sleep(wait)
655
+ continue
656
+ raise
657
+
658
+
659
+ def cleanup_image_cache(max_age_hours: int = 24) -> int:
660
+ """
661
+ Delete cached images older than *max_age_hours*.
662
+
663
+ Returns the number of files removed.
664
+ """
665
+ import time
666
+
667
+ cache_dir = get_image_cache_dir()
668
+ cutoff = time.time() - (max_age_hours * 3600)
669
+ removed = 0
670
+ for f in cache_dir.iterdir():
671
+ if f.is_file() and f.stat().st_mtime < cutoff:
672
+ try:
673
+ f.unlink()
674
+ removed += 1
675
+ except OSError:
676
+ pass
677
+ return removed
678
+
679
+
680
+ # ---------------------------------------------------------------------------
681
+ # Audio cache utilities
682
+ #
683
+ # Same pattern as image cache -- voice messages from platforms are downloaded
684
+ # here so the STT tool (OpenAI Whisper) can transcribe them from local files.
685
+ # ---------------------------------------------------------------------------
686
+
687
+ AUDIO_CACHE_DIR = get_hermes_dir("cache/audio", "audio_cache")
688
+
689
+
690
+ def get_audio_cache_dir() -> Path:
691
+ """Return the audio cache directory, creating it if it doesn't exist."""
692
+ AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
693
+ return AUDIO_CACHE_DIR
694
+
695
+
696
+ def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
697
+ """
698
+ Save raw audio bytes to the cache and return the absolute file path.
699
+
700
+ Args:
701
+ data: Raw audio bytes.
702
+ ext: File extension including the dot (e.g. ".ogg", ".mp3").
703
+
704
+ Returns:
705
+ Absolute path to the cached audio file as a string.
706
+ """
707
+ cache_dir = get_audio_cache_dir()
708
+ filename = f"audio_{uuid.uuid4().hex[:12]}{ext}"
709
+ filepath = cache_dir / filename
710
+ filepath.write_bytes(data)
711
+ return str(filepath)
712
+
713
+
714
+ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> str:
715
+ """
716
+ Download an audio file from a URL and save it to the local cache.
717
+
718
+ Retries on transient failures (timeouts, 429, 5xx) with exponential
719
+ backoff so a single slow CDN response doesn't lose the media.
720
+
721
+ Args:
722
+ url: The HTTP/HTTPS URL to download from.
723
+ ext: File extension including the dot (e.g. ".ogg", ".mp3").
724
+ retries: Number of retry attempts on transient failures.
725
+
726
+ Returns:
727
+ Absolute path to the cached audio file as a string.
728
+
729
+ Raises:
730
+ ValueError: If the URL targets a private/internal network (SSRF protection).
731
+ """
732
+ from tools.url_safety import is_safe_url
733
+ if not is_safe_url(url):
734
+ raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}")
735
+
736
+ import httpx
737
+ _log = logging.getLogger(__name__)
738
+
739
+ async with httpx.AsyncClient(
740
+ timeout=30.0,
741
+ follow_redirects=True,
742
+ event_hooks={"response": [_ssrf_redirect_guard]},
743
+ ) as client:
744
+ for attempt in range(retries + 1):
745
+ try:
746
+ response = await client.get(
747
+ url,
748
+ headers={
749
+ "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
750
+ "Accept": "audio/*,*/*;q=0.8",
751
+ },
752
+ )
753
+ response.raise_for_status()
754
+ return cache_audio_from_bytes(response.content, ext)
755
+ except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
756
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
757
+ raise
758
+ if attempt < retries:
759
+ wait = 1.5 * (attempt + 1)
760
+ _log.debug(
761
+ "Audio cache retry %d/%d for %s (%.1fs): %s",
762
+ attempt + 1,
763
+ retries,
764
+ safe_url_for_log(url),
765
+ wait,
766
+ exc,
767
+ )
768
+ await asyncio.sleep(wait)
769
+ continue
770
+ raise
771
+
772
+
773
+ # ---------------------------------------------------------------------------
774
+ # Video cache utilities
775
+ #
776
+ # Same pattern as image/audio cache -- videos from platforms are downloaded
777
+ # here so the agent can reference them by local file path.
778
+ # ---------------------------------------------------------------------------
779
+
780
+ VIDEO_CACHE_DIR = get_hermes_dir("cache/videos", "video_cache")
781
+
782
+ SUPPORTED_VIDEO_TYPES = {
783
+ ".mp4": "video/mp4",
784
+ ".mov": "video/quicktime",
785
+ ".webm": "video/webm",
786
+ ".mkv": "video/x-matroska",
787
+ ".avi": "video/x-msvideo",
788
+ }
789
+
790
+
791
+ def get_video_cache_dir() -> Path:
792
+ """Return the video cache directory, creating it if it doesn't exist."""
793
+ VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
794
+ return VIDEO_CACHE_DIR
795
+
796
+
797
+ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
798
+ """Save raw video bytes to the cache and return the absolute file path."""
799
+ cache_dir = get_video_cache_dir()
800
+ filename = f"video_{uuid.uuid4().hex[:12]}{ext}"
801
+ filepath = cache_dir / filename
802
+ filepath.write_bytes(data)
803
+ return str(filepath)
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # Document cache utilities
808
+ #
809
+ # Same pattern as image/audio cache -- documents from platforms are downloaded
810
+ # here so the agent can reference them by local file path.
811
+ # ---------------------------------------------------------------------------
812
+
813
+ DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
814
+
815
+ SUPPORTED_DOCUMENT_TYPES = {
816
+ ".pdf": "application/pdf",
817
+ ".md": "text/markdown",
818
+ ".txt": "text/plain",
819
+ ".csv": "text/csv",
820
+ ".log": "text/plain",
821
+ ".json": "application/json",
822
+ ".xml": "application/xml",
823
+ ".yaml": "application/yaml",
824
+ ".yml": "application/yaml",
825
+ ".toml": "application/toml",
826
+ ".ini": "text/plain",
827
+ ".cfg": "text/plain",
828
+ ".zip": "application/zip",
829
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
830
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
831
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
832
+ }
833
+
834
+
835
+ def get_document_cache_dir() -> Path:
836
+ """Return the document cache directory, creating it if it doesn't exist."""
837
+ DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
838
+ return DOCUMENT_CACHE_DIR
839
+
840
+
841
+ def cache_document_from_bytes(data: bytes, filename: str) -> str:
842
+ """
843
+ Save raw document bytes to the cache and return the absolute file path.
844
+
845
+ The cached filename preserves the original human-readable name with a
846
+ unique prefix: ``doc_{uuid12}_{original_filename}``.
847
+
848
+ Args:
849
+ data: Raw document bytes.
850
+ filename: Original filename (e.g. "report.pdf").
851
+
852
+ Returns:
853
+ Absolute path to the cached document file as a string.
854
+
855
+ Raises:
856
+ ValueError: If the sanitized path escapes the cache directory.
857
+ """
858
+ cache_dir = get_document_cache_dir()
859
+ # Sanitize: strip directory components, null bytes, and control characters
860
+ safe_name = Path(filename).name if filename else "document"
861
+ safe_name = safe_name.replace("\x00", "").strip()
862
+ if not safe_name or safe_name in {".", ".."}:
863
+ safe_name = "document"
864
+ cached_name = f"doc_{uuid.uuid4().hex[:12]}_{safe_name}"
865
+ filepath = cache_dir / cached_name
866
+ # Final safety check: ensure path stays inside cache dir
867
+ if not filepath.resolve().is_relative_to(cache_dir.resolve()):
868
+ raise ValueError(f"Path traversal rejected: {filename!r}")
869
+ filepath.write_bytes(data)
870
+ return str(filepath)
871
+
872
+
873
+ def cleanup_document_cache(max_age_hours: int = 24) -> int:
874
+ """
875
+ Delete cached documents older than *max_age_hours*.
876
+
877
+ Returns the number of files removed.
878
+ """
879
+ import time
880
+
881
+ cache_dir = get_document_cache_dir()
882
+ cutoff = time.time() - (max_age_hours * 3600)
883
+ removed = 0
884
+ for f in cache_dir.iterdir():
885
+ if f.is_file() and f.stat().st_mtime < cutoff:
886
+ try:
887
+ f.unlink()
888
+ removed += 1
889
+ except OSError:
890
+ pass
891
+ return removed
892
+
893
+
894
+ class MessageType(Enum):
895
+ """Types of incoming messages."""
896
+ TEXT = "text"
897
+ LOCATION = "location"
898
+ PHOTO = "photo"
899
+ VIDEO = "video"
900
+ AUDIO = "audio"
901
+ VOICE = "voice"
902
+ DOCUMENT = "document"
903
+ STICKER = "sticker"
904
+ COMMAND = "command" # /command style
905
+
906
+
907
+ class ProcessingOutcome(Enum):
908
+ """Result classification for message-processing lifecycle hooks."""
909
+
910
+ SUCCESS = "success"
911
+ FAILURE = "failure"
912
+ CANCELLED = "cancelled"
913
+
914
+
915
+ @dataclass
916
+ class MessageEvent:
917
+ """
918
+ Incoming message from a platform.
919
+
920
+ Normalized representation that all adapters produce.
921
+ """
922
+ # Message content
923
+ text: str
924
+ message_type: MessageType = MessageType.TEXT
925
+
926
+ # Source information
927
+ source: SessionSource = None
928
+
929
+ # Original platform data
930
+ raw_message: Any = None
931
+ message_id: Optional[str] = None
932
+
933
+ # Platform-specific update identifier. For Telegram this is the
934
+ # ``update_id`` from the PTB Update wrapper; other platforms currently
935
+ # ignore it. Used by ``/restart`` to record the triggering update so the
936
+ # new gateway can advance the Telegram offset past it and avoid processing
937
+ # the same ``/restart`` twice if PTB's graceful-shutdown ACK times out
938
+ # ("Error while calling `get_updates` one more time to mark all fetched
939
+ # updates" in gateway.log).
940
+ platform_update_id: Optional[int] = None
941
+
942
+ # Media attachments
943
+ # media_urls: local file paths (for vision tool access)
944
+ media_urls: List[str] = field(default_factory=list)
945
+ media_types: List[str] = field(default_factory=list)
946
+
947
+ # Reply context
948
+ reply_to_message_id: Optional[str] = None
949
+ reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection)
950
+
951
+ # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics,
952
+ # Discord channel_skill_bindings). A single name or ordered list.
953
+ auto_skill: Optional[str | list[str]] = None
954
+
955
+ # Per-channel ephemeral system prompt (e.g. Discord channel_prompts).
956
+ # Applied at API call time and never persisted to transcript history.
957
+ channel_prompt: Optional[str] = None
958
+
959
+ # Channel context recovered by history backfill (e.g. messages between
960
+ # bot turns that were missed due to require_mention). Kept separate
961
+ # from ``text`` so the sender-prefix logic in run.py can operate on the
962
+ # trigger message alone, then prepend this context afterward.
963
+ channel_context: Optional[str] = None
964
+
965
+ # Internal flag — set for synthetic events (e.g. background process
966
+ # completion notifications) that must bypass user authorization checks.
967
+ internal: bool = False
968
+
969
+ # Timestamps
970
+ timestamp: datetime = field(default_factory=datetime.now)
971
+
972
+ def is_command(self) -> bool:
973
+ """Check if this is a command message (e.g., /new, /reset)."""
974
+ return self.text.startswith("/")
975
+
976
+ def get_command(self) -> Optional[str]:
977
+ """Extract command name if this is a command message."""
978
+ if not self.is_command():
979
+ return None
980
+ # Split on space and get first word, strip the /
981
+ parts = self.text.split(maxsplit=1)
982
+ raw = parts[0][1:].lower() if parts else None
983
+ if raw and "@" in raw:
984
+ raw = raw.split("@", 1)[0]
985
+ # Reject file paths: valid command names never contain /
986
+ if raw and "/" in raw:
987
+ return None
988
+ return raw
989
+
990
+ def get_command_args(self) -> str:
991
+ """Get the arguments after a command."""
992
+ if not self.is_command():
993
+ return self.text
994
+ parts = self.text.split(maxsplit=1)
995
+ args = parts[1] if len(parts) > 1 else ""
996
+ # iOS auto-corrects -- to — (em dash) and - to – (en dash)
997
+ args = args.replace("\u2014\u2014", "--").replace("\u2014", "--").replace("\u2013", "-")
998
+ return args
999
+
1000
+
1001
+ _PLAINTEXT_GATEWAY_RESTART_PATTERNS: tuple[re.Pattern[str], ...] = (
1002
+ re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?gateway[.!?\s]*$", re.IGNORECASE),
1003
+ re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?hermes\s+gateway[.!?\s]*$", re.IGNORECASE),
1004
+ re.compile(r"^(?:please\s+)?restart\s+hermes[.!?\s]*$", re.IGNORECASE),
1005
+ )
1006
+
1007
+
1008
+ def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
1009
+ """Rewrite a tiny set of DM plaintext admin phrases into slash commands.
1010
+
1011
+ This keeps high-impact operational phrases like ``restart gateway`` out of
1012
+ the LLM/tool path, where they can trigger a self-restart from inside the
1013
+ currently running agent and leave the gateway stuck in ``draining`` while it
1014
+ waits for that same agent to finish.
1015
+
1016
+ Scope is intentionally narrow: DM text messages only, exact restart-style
1017
+ phrases only. Group chats keep natural-language semantics.
1018
+ """
1019
+ try:
1020
+ if event is None or event.message_type != MessageType.TEXT:
1021
+ return
1022
+ text = (event.text or "").strip()
1023
+ if not text or text.startswith("/"):
1024
+ return
1025
+ source = getattr(event, "source", None)
1026
+ if getattr(source, "chat_type", None) != "dm":
1027
+ return
1028
+ for pattern in _PLAINTEXT_GATEWAY_RESTART_PATTERNS:
1029
+ if pattern.match(text):
1030
+ event.text = "/restart"
1031
+ return
1032
+ except Exception:
1033
+ return
1034
+
1035
+
1036
+ @dataclass
1037
+ class SendResult:
1038
+ """Result of sending a message."""
1039
+ success: bool
1040
+ message_id: Optional[str] = None
1041
+ error: Optional[str] = None
1042
+ raw_response: Any = None
1043
+ retryable: bool = False # True for transient connection errors — base will retry automatically
1044
+ # When the adapter had to split an oversized payload across multiple
1045
+ # platform messages (e.g. Telegram edit_message overflow split-and-deliver),
1046
+ # ``message_id`` is the LAST visible message id (so subsequent edits target
1047
+ # the most recent chunk) and these are the additional message ids that
1048
+ # made up the full payload, in send order. Empty tuple for the common
1049
+ # single-message case.
1050
+ continuation_message_ids: tuple = ()
1051
+
1052
+
1053
+ class EphemeralReply(str):
1054
+ """System-notice reply that auto-deletes after a TTL.
1055
+
1056
+ Slash-command handlers in ``gateway/run.py`` can return this wrapper
1057
+ instead of a plain string to request that the reply message be deleted
1058
+ after ``ttl_seconds`` on platforms that support ``delete_message``.
1059
+
1060
+ Subclassing ``str`` keeps the wrapper transparent to anything that
1061
+ treats handler return values as text (existing tests use ``in`` /
1062
+ ``startswith`` / equality; the ``_process_message_background`` pipeline
1063
+ extracts attachments from the string content). ``isinstance(r,
1064
+ EphemeralReply)`` still distinguishes ephemeral replies from plain
1065
+ strings so the send path can schedule deletion.
1066
+
1067
+ Platforms that don't override :meth:`BasePlatformAdapter.delete_message`
1068
+ silently ignore the TTL — the message is sent normally and left in
1069
+ place. When ``ttl_seconds`` is ``None``, the pipeline uses the
1070
+ configured ``display.ephemeral_system_ttl`` default. A default of ``0``
1071
+ disables auto-deletion globally, preserving prior behavior.
1072
+ """
1073
+
1074
+ ttl_seconds: Optional[int]
1075
+
1076
+ def __new__(cls, text: str, ttl_seconds: Optional[int] = None):
1077
+ instance = super().__new__(cls, text)
1078
+ instance.ttl_seconds = ttl_seconds
1079
+ return instance
1080
+
1081
+ @property
1082
+ def text(self) -> str:
1083
+ """Return the underlying text.
1084
+
1085
+ Provided for call sites that want an explicit string conversion,
1086
+ though ``str(reply)`` and using ``reply`` directly where a string
1087
+ is expected both work identically.
1088
+ """
1089
+ return str.__str__(self)
1090
+
1091
+
1092
+ def merge_pending_message_event(
1093
+ pending_messages: Dict[str, MessageEvent],
1094
+ session_key: str,
1095
+ event: MessageEvent,
1096
+ *,
1097
+ merge_text: bool = False,
1098
+ ) -> None:
1099
+ """Store or merge a pending event for a session.
1100
+
1101
+ Photo bursts/albums often arrive as multiple near-simultaneous PHOTO
1102
+ events. Merge those into the existing queued event so the next turn sees
1103
+ the whole burst.
1104
+
1105
+ When ``merge_text`` is enabled, rapid follow-up TEXT events are appended
1106
+ instead of replacing the pending turn. This is used for Telegram bursty
1107
+ follow-ups so a multi-part user thought is not silently truncated to only
1108
+ the last queued fragment.
1109
+ """
1110
+ existing = pending_messages.get(session_key)
1111
+ if existing:
1112
+ existing_is_photo = getattr(existing, "message_type", None) == MessageType.PHOTO
1113
+ incoming_is_photo = event.message_type == MessageType.PHOTO
1114
+ existing_has_media = bool(existing.media_urls)
1115
+ incoming_has_media = bool(event.media_urls)
1116
+
1117
+ if existing_is_photo and incoming_is_photo:
1118
+ existing.media_urls.extend(event.media_urls)
1119
+ existing.media_types.extend(event.media_types)
1120
+ if event.text:
1121
+ existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
1122
+ return
1123
+
1124
+ if existing_has_media or incoming_has_media:
1125
+ if incoming_has_media:
1126
+ existing.media_urls.extend(event.media_urls)
1127
+ existing.media_types.extend(event.media_types)
1128
+ if event.text:
1129
+ if existing.text:
1130
+ existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
1131
+ else:
1132
+ existing.text = event.text
1133
+ if existing_is_photo or incoming_is_photo:
1134
+ existing.message_type = MessageType.PHOTO
1135
+ elif (
1136
+ getattr(existing, "message_type", None) == MessageType.TEXT
1137
+ and event.message_type != MessageType.TEXT
1138
+ ):
1139
+ existing.message_type = event.message_type
1140
+ return
1141
+
1142
+ if (
1143
+ merge_text
1144
+ and getattr(existing, "message_type", None) == MessageType.TEXT
1145
+ and event.message_type == MessageType.TEXT
1146
+ ):
1147
+ if event.text:
1148
+ existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
1149
+ return
1150
+
1151
+ pending_messages[session_key] = event
1152
+
1153
+
1154
+ # Error substrings that indicate a transient *connection* failure worth retrying.
1155
+ # "timeout" / "timed out" / "readtimeout" / "writetimeout" are intentionally
1156
+ # excluded: a read/write timeout on a non-idempotent call (e.g. send_message)
1157
+ # means the request may have reached the server — retrying risks duplicate
1158
+ # delivery. "connecttimeout" is safe because the connection was never
1159
+ # established. Platforms that know a timeout is safe to retry should set
1160
+ # SendResult.retryable = True explicitly.
1161
+ _RETRYABLE_ERROR_PATTERNS = (
1162
+ "connecterror",
1163
+ "connectionerror",
1164
+ "connectionreset",
1165
+ "connectionrefused",
1166
+ "connecttimeout",
1167
+ "network",
1168
+ "broken pipe",
1169
+ "remotedisconnected",
1170
+ "eoferror",
1171
+ )
1172
+
1173
+
1174
+ # Type for message handlers. Handlers may return a plain string (normal
1175
+ # reply), an ``EphemeralReply`` to opt the reply into auto-deletion, or
1176
+ # ``None`` when the response was already delivered (e.g. via streaming).
1177
+ MessageHandler = Callable[[MessageEvent], Awaitable[Optional[Union[str, "EphemeralReply"]]]]
1178
+
1179
+
1180
+ def resolve_channel_prompt(
1181
+ config_extra: dict,
1182
+ channel_id: str,
1183
+ parent_id: str | None = None,
1184
+ ) -> str | None:
1185
+ """Resolve a per-channel ephemeral prompt from platform config.
1186
+
1187
+ Looks up ``channel_prompts`` in the adapter's ``config.extra`` dict.
1188
+ Prefers an exact match on *channel_id*; falls back to *parent_id*
1189
+ (useful for forum threads / child channels inheriting a parent prompt).
1190
+
1191
+ Returns the prompt string, or None if no match is found. Blank/whitespace-
1192
+ only prompts are treated as absent.
1193
+ """
1194
+ prompts = config_extra.get("channel_prompts") or {}
1195
+ if not isinstance(prompts, dict):
1196
+ return None
1197
+
1198
+ for key in (channel_id, parent_id):
1199
+ if not key:
1200
+ continue
1201
+ prompt = prompts.get(key)
1202
+ if prompt is None:
1203
+ continue
1204
+ prompt = str(prompt).strip()
1205
+ if prompt:
1206
+ return prompt
1207
+ return None
1208
+
1209
+
1210
+ def resolve_channel_skills(
1211
+ config_extra: dict,
1212
+ channel_id: str,
1213
+ parent_id: str | None = None,
1214
+ ) -> list[str] | None:
1215
+ """Resolve auto-loaded skill(s) for a channel/thread from platform config.
1216
+
1217
+ Looks up ``channel_skill_bindings`` in the adapter's ``config.extra`` dict.
1218
+
1219
+ Config format::
1220
+
1221
+ channel_skill_bindings:
1222
+ - id: "C0123" # Slack channel ID or Discord channel/forum ID
1223
+ skills: ["skill-a", "skill-b"]
1224
+ - id: "D0ABCDE"
1225
+ skill: "solo-skill" # single string also accepted
1226
+
1227
+ Prefers an exact match on *channel_id*; falls back to *parent_id*
1228
+ (useful for forum threads / Slack threads inheriting the parent channel's
1229
+ binding).
1230
+
1231
+ Returns a deduplicated list of skill names (order preserved), or None if
1232
+ no match is found.
1233
+ """
1234
+ bindings = config_extra.get("channel_skill_bindings") or []
1235
+ if not isinstance(bindings, list) or not bindings:
1236
+ return None
1237
+ ids_to_check: set[str] = set()
1238
+ if channel_id:
1239
+ ids_to_check.add(str(channel_id))
1240
+ if parent_id:
1241
+ ids_to_check.add(str(parent_id))
1242
+ if not ids_to_check:
1243
+ return None
1244
+ for entry in bindings:
1245
+ if not isinstance(entry, dict):
1246
+ continue
1247
+ entry_id = str(entry.get("id", ""))
1248
+ if entry_id in ids_to_check:
1249
+ skills = entry.get("skills") or entry.get("skill")
1250
+ if isinstance(skills, str):
1251
+ s = skills.strip()
1252
+ return [s] if s else None
1253
+ if isinstance(skills, list) and skills:
1254
+ seen: list[str] = []
1255
+ for name in skills:
1256
+ if not isinstance(name, str):
1257
+ continue
1258
+ nm = name.strip()
1259
+ if nm and nm not in seen:
1260
+ seen.append(nm)
1261
+ return seen or None
1262
+ return None
1263
+
1264
+
1265
+ class BasePlatformAdapter(ABC):
1266
+ """
1267
+ Base class for platform adapters.
1268
+
1269
+ Subclasses implement platform-specific logic for:
1270
+ - Connecting and authenticating
1271
+ - Receiving messages
1272
+ - Sending messages/responses
1273
+ - Handling media
1274
+ """
1275
+
1276
+ def __init__(self, config: PlatformConfig, platform: Platform):
1277
+ self.config = config
1278
+ self.platform = platform
1279
+ self._message_handler: Optional[MessageHandler] = None
1280
+ self._running = False
1281
+ self._fatal_error_code: Optional[str] = None
1282
+ self._fatal_error_message: Optional[str] = None
1283
+ self._fatal_error_retryable = True
1284
+ self._fatal_error_handler: Optional[Callable[["BasePlatformAdapter"], Awaitable[None] | None]] = None
1285
+
1286
+ # Track active message handlers per session for interrupt support.
1287
+ # _active_sessions stores the per-session interrupt Event; _session_tasks
1288
+ # maps session → the specific Task currently processing it so that
1289
+ # session-terminating commands (/stop, /new, /reset) can cancel the
1290
+ # right task and release the adapter-level guard deterministically.
1291
+ # Without the owner-task map, an old task's finally block could delete
1292
+ # a newer task's guard, leaving stale busy state.
1293
+ self._active_sessions: Dict[str, asyncio.Event] = {}
1294
+ self._pending_messages: Dict[str, MessageEvent] = {}
1295
+ self._session_tasks: Dict[str, asyncio.Task] = {}
1296
+ # Background message-processing tasks spawned by handle_message().
1297
+ # Gateway shutdown cancels these so an old gateway instance doesn't keep
1298
+ # working on a task after --replace or manual restarts.
1299
+ self._background_tasks: set[asyncio.Task] = set()
1300
+ # One-shot callbacks to fire after the main response is delivered.
1301
+ # Keyed by session_key. Values are either a bare callback (legacy) or
1302
+ # a ``(generation, callback)`` tuple so GatewayRunner can make deferred
1303
+ # deliveries generation-aware and avoid stale runs clearing callbacks
1304
+ # registered by a fresher run for the same session.
1305
+ self._post_delivery_callbacks: Dict[str, Any] = {}
1306
+ self._expected_cancelled_tasks: set[asyncio.Task] = set()
1307
+ self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None
1308
+ # Auto-TTS on voice input: ``_auto_tts_default`` is the global default
1309
+ # (``voice.auto_tts`` in config.yaml, pushed by GatewayRunner on connect).
1310
+ # Per-chat overrides live in two sets populated from ``_voice_mode``:
1311
+ # - ``_auto_tts_enabled_chats``: chat explicitly opted in via ``/voice on``
1312
+ # or ``/voice tts`` (mode is ``voice_only`` or ``all``). Fires even when
1313
+ # the global default is False.
1314
+ # - ``_auto_tts_disabled_chats``: chat explicitly opted out via
1315
+ # ``/voice off`` (mode is ``off``). Suppresses auto-TTS even when the
1316
+ # global default is True.
1317
+ # The gate in _process_message() is:
1318
+ # fire if chat in _auto_tts_enabled_chats
1319
+ # OR (_auto_tts_default and chat not in _auto_tts_disabled_chats)
1320
+ self._auto_tts_default: bool = False
1321
+ self._auto_tts_enabled_chats: set = set()
1322
+ self._auto_tts_disabled_chats: set = set()
1323
+ # Chats where typing indicator is paused (e.g. during approval waits).
1324
+ # _keep_typing skips send_typing when the chat_id is in this set.
1325
+ self._typing_paused: set = set()
1326
+
1327
+ @property
1328
+ def message_len_fn(self) -> Callable[[str], int]:
1329
+ """Return the length function for measuring message size on this platform.
1330
+
1331
+ Override in adapters whose platform counts characters differently from
1332
+ Python ``len`` (e.g. Telegram counts UTF-16 code units).
1333
+ """
1334
+ return len
1335
+
1336
+ def supports_draft_streaming(
1337
+ self,
1338
+ chat_type: Optional[str] = None,
1339
+ metadata: Optional[Dict[str, Any]] = None,
1340
+ ) -> bool:
1341
+ """Whether this adapter supports native streaming-draft updates.
1342
+
1343
+ Telegram Bot API 9.5 introduced ``sendMessageDraft``, which renders an
1344
+ animated streaming preview as the bot calls it repeatedly with the
1345
+ same ``draft_id`` and growing text. Adapters that implement
1346
+ ``send_draft`` should return True here for the chat types where the
1347
+ platform supports it (Telegram restricts drafts to private DMs).
1348
+
1349
+ Default implementation returns False. Stream consumers fall back to
1350
+ the edit-based path (``send`` + ``edit_message``) when this returns
1351
+ False or when ``send_draft`` raises.
1352
+ """
1353
+ return False
1354
+
1355
+ async def send_draft(
1356
+ self,
1357
+ chat_id: str,
1358
+ draft_id: int,
1359
+ content: str,
1360
+ metadata: Optional[Dict[str, Any]] = None,
1361
+ ) -> SendResult:
1362
+ """Send or update an animated streaming-draft preview.
1363
+
1364
+ Reuse the same ``draft_id`` (any non-zero int) across consecutive
1365
+ calls within a single response so the platform animates the preview
1366
+ rather than re-creating it. Different responses must use different
1367
+ ``draft_id`` values within the same chat to avoid animating over a
1368
+ prior bubble.
1369
+
1370
+ Drafts have no message_id and cannot be edited, replied to, or
1371
+ deleted via normal message APIs. When the response finishes, the
1372
+ caller delivers the final answer as a regular ``send`` and the
1373
+ draft preview clears naturally on the client.
1374
+
1375
+ Default implementation raises NotImplementedError; adapters that
1376
+ also return True from :meth:`supports_draft_streaming` must override.
1377
+ """
1378
+ raise NotImplementedError(
1379
+ f"{type(self).__name__} does not implement send_draft"
1380
+ )
1381
+
1382
+ @property
1383
+ def has_fatal_error(self) -> bool:
1384
+ return self._fatal_error_message is not None
1385
+
1386
+ @property
1387
+ def fatal_error_message(self) -> Optional[str]:
1388
+ return self._fatal_error_message
1389
+
1390
+ @property
1391
+ def fatal_error_code(self) -> Optional[str]:
1392
+ return self._fatal_error_code
1393
+
1394
+ @property
1395
+ def fatal_error_retryable(self) -> bool:
1396
+ return self._fatal_error_retryable
1397
+
1398
+ def _should_auto_tts_for_chat(self, chat_id: str) -> bool:
1399
+ """Whether auto-TTS on voice input should fire for ``chat_id``.
1400
+
1401
+ Decision layers (Issue #16007):
1402
+ 1. Explicit ``/voice on`` or ``/voice tts`` → always fire (even if
1403
+ ``voice.auto_tts`` is False).
1404
+ 2. Explicit ``/voice off`` → never fire.
1405
+ 3. Fall back to the global ``voice.auto_tts`` config default.
1406
+ """
1407
+ if chat_id in self._auto_tts_enabled_chats:
1408
+ return True
1409
+ if chat_id in self._auto_tts_disabled_chats:
1410
+ return False
1411
+ return bool(self._auto_tts_default)
1412
+
1413
+ def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None:
1414
+ self._fatal_error_handler = handler
1415
+
1416
+ def _mark_connected(self) -> None:
1417
+ self._running = True
1418
+ self._fatal_error_code = None
1419
+ self._fatal_error_message = None
1420
+ self._fatal_error_retryable = True
1421
+ self._write_runtime_status_safe("connected", platform_state="connected", error_code=None, error_message=None)
1422
+
1423
+ def _mark_disconnected(self) -> None:
1424
+ self._running = False
1425
+ if self.has_fatal_error:
1426
+ return
1427
+ self._write_runtime_status_safe("disconnected", platform_state="disconnected", error_code=None, error_message=None)
1428
+
1429
+ def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None:
1430
+ self._running = False
1431
+ self._fatal_error_code = code
1432
+ self._fatal_error_message = message
1433
+ self._fatal_error_retryable = retryable
1434
+ self._write_runtime_status_safe("fatal", platform_state="fatal", error_code=code, error_message=message)
1435
+
1436
+ def _write_runtime_status_safe(self, context: str, **kwargs) -> None:
1437
+ """Write runtime status; log first failure per context at warning, rest at debug.
1438
+
1439
+ Status writes can fail on permissions, ENOSPC, missing status dir, etc.
1440
+ A persistently failing status dir used to be silent (``except: pass``).
1441
+ Logging every failure would spam the log on reconnect loops, so this
1442
+ surfaces the first failure per (platform, context) at warning level and
1443
+ downgrades subsequent failures to debug.
1444
+ """
1445
+ try:
1446
+ from gateway.status import write_runtime_status
1447
+ write_runtime_status(platform=self.platform.value, **kwargs)
1448
+ except Exception as exc:
1449
+ # Use getattr so object.__new__(...) test harnesses that skip __init__
1450
+ # don't blow up on attribute access.
1451
+ logged = getattr(self, "_status_write_logged", None)
1452
+ if logged is None:
1453
+ logged = set()
1454
+ try:
1455
+ self._status_write_logged = logged
1456
+ except Exception:
1457
+ pass
1458
+ key = (self.platform.value, context)
1459
+ if key not in logged:
1460
+ logger.warning(
1461
+ "Failed to write runtime status (%s) for %s: %s (further failures at debug level)",
1462
+ context, self.platform.value, exc,
1463
+ )
1464
+ logged.add(key)
1465
+ else:
1466
+ logger.debug("Failed to write runtime status (%s) for %s: %s", context, self.platform.value, exc)
1467
+
1468
+ async def _notify_fatal_error(self) -> None:
1469
+ handler = self._fatal_error_handler
1470
+ if not handler:
1471
+ return
1472
+ result = handler(self)
1473
+ if asyncio.iscoroutine(result):
1474
+ await result
1475
+
1476
+ def _acquire_platform_lock(self, scope: str, identity: str, resource_desc: str) -> bool:
1477
+ """Acquire a scoped lock for this adapter. Returns True on success."""
1478
+ from gateway.status import acquire_scoped_lock
1479
+ self._platform_lock_scope = scope
1480
+ self._platform_lock_identity = identity
1481
+ acquired, existing = acquire_scoped_lock(
1482
+ scope, identity, metadata={'platform': self.platform.value}
1483
+ )
1484
+ if acquired:
1485
+ return True
1486
+ owner_pid = existing.get('pid') if isinstance(existing, dict) else None
1487
+ message = (
1488
+ f'{resource_desc} already in use'
1489
+ + (f' (PID {owner_pid})' if owner_pid else '')
1490
+ + '. Stop the other gateway first.'
1491
+ )
1492
+ logger.error('[%s] %s', self.name, message)
1493
+ self._set_fatal_error(f'{scope}_lock', message, retryable=False)
1494
+ return False
1495
+
1496
+ def _release_platform_lock(self) -> None:
1497
+ """Release the scoped lock acquired by _acquire_platform_lock."""
1498
+ identity = getattr(self, '_platform_lock_identity', None)
1499
+ if not identity:
1500
+ return
1501
+ from gateway.status import release_scoped_lock
1502
+ release_scoped_lock(self._platform_lock_scope, identity)
1503
+ self._platform_lock_identity = None
1504
+
1505
+ @property
1506
+ def name(self) -> str:
1507
+ """Human-readable name for this adapter."""
1508
+ return self.platform.value.title()
1509
+
1510
+ @property
1511
+ def is_connected(self) -> bool:
1512
+ """Check if adapter is currently connected."""
1513
+ return self._running
1514
+
1515
+ def set_message_handler(self, handler: MessageHandler) -> None:
1516
+ """
1517
+ Set the handler for incoming messages.
1518
+
1519
+ The handler receives a MessageEvent and should return
1520
+ an optional response string.
1521
+ """
1522
+ self._message_handler = handler
1523
+
1524
+ def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:
1525
+ """Set an optional handler for messages arriving during active sessions."""
1526
+ self._busy_session_handler = handler
1527
+
1528
+ def set_session_store(self, session_store: Any) -> None:
1529
+ """
1530
+ Set the session store for checking active sessions.
1531
+
1532
+ Used by adapters that need to check if a thread/conversation
1533
+ has an active session before processing messages (e.g., Slack
1534
+ thread replies without explicit mentions).
1535
+ """
1536
+ self._session_store = session_store
1537
+
1538
+ @abstractmethod
1539
+ async def connect(self) -> bool:
1540
+ """
1541
+ Connect to the platform and start receiving messages.
1542
+
1543
+ Returns True if connection was successful.
1544
+ """
1545
+ pass
1546
+
1547
+ @abstractmethod
1548
+ async def disconnect(self) -> None:
1549
+ """Disconnect from the platform."""
1550
+ pass
1551
+
1552
+ @abstractmethod
1553
+ async def send(
1554
+ self,
1555
+ chat_id: str,
1556
+ content: str,
1557
+ reply_to: Optional[str] = None,
1558
+ metadata: Optional[Dict[str, Any]] = None
1559
+ ) -> SendResult:
1560
+ """
1561
+ Send a message to a chat.
1562
+
1563
+ Args:
1564
+ chat_id: The chat/channel ID to send to
1565
+ content: Message content (may be markdown)
1566
+ reply_to: Optional message ID to reply to
1567
+ metadata: Additional platform-specific options
1568
+
1569
+ Returns:
1570
+ SendResult with success status and message ID
1571
+ """
1572
+ pass
1573
+
1574
+ # Default: the adapter treats ``finalize=True`` on edit_message as a
1575
+ # no-op and is happy to have the stream consumer skip redundant final
1576
+ # edits. Subclasses that *require* an explicit finalize call to close
1577
+ # out the message lifecycle (e.g. rich card / AI assistant surfaces
1578
+ # such as DingTalk AI Cards) override this to True (class attribute or
1579
+ # property) so the stream consumer knows not to short-circuit.
1580
+ REQUIRES_EDIT_FINALIZE: bool = False
1581
+
1582
+ async def create_handoff_thread(
1583
+ self,
1584
+ parent_chat_id: str,
1585
+ name: str,
1586
+ ) -> Optional[str]:
1587
+ """Create a fresh thread under ``parent_chat_id`` for a session handoff.
1588
+
1589
+ Used by the gateway's handoff watcher when transferring a CLI
1590
+ session to a thread-capable platform — the new thread isolates the
1591
+ handed-off conversation from any pre-existing chat in the home
1592
+ channel and gives users a clean per-handoff scrollback.
1593
+
1594
+ Returns the new thread/topic id (as a string) on success, or
1595
+ ``None`` if the platform doesn't support threading or the
1596
+ attempt failed (permissions, topics-mode off, etc.). When ``None``
1597
+ is returned the watcher falls back to using ``parent_chat_id``
1598
+ directly.
1599
+
1600
+ Default implementation returns ``None`` — adapters that support
1601
+ threads override this. See:
1602
+ - Telegram: forum topics in groups, DM topics with bot API 9.4+
1603
+ - Discord: text-channel threads (1440-min auto-archive)
1604
+ - Slack: seed-message thread anchoring
1605
+ """
1606
+ return None
1607
+
1608
+
1609
+ async def edit_message(
1610
+ self,
1611
+ chat_id: str,
1612
+ message_id: str,
1613
+ content: str,
1614
+ *,
1615
+ finalize: bool = False,
1616
+ ) -> SendResult:
1617
+ """
1618
+ Edit a previously sent message. Optional — platforms that don't
1619
+ support editing return success=False and callers fall back to
1620
+ sending a new message.
1621
+
1622
+ ``finalize`` signals that this is the last edit in a streaming
1623
+ sequence. Most platforms (Telegram, Slack, Discord, Matrix,
1624
+ etc.) treat it as a no-op because their edit APIs have no notion
1625
+ of message lifecycle state — an edit is an edit. Platforms that
1626
+ render streaming updates with a distinct "in progress" state and
1627
+ require explicit closure (e.g. rich card / AI assistant surfaces
1628
+ such as DingTalk AI Cards) use it to finalize the message and
1629
+ transition the UI out of the streaming indicator — those should
1630
+ also set ``REQUIRES_EDIT_FINALIZE = True`` so callers route a
1631
+ final edit through even when content is unchanged. Callers
1632
+ should set ``finalize=True`` on the final edit of a streamed
1633
+ response (typically when ``got_done`` fires in the stream
1634
+ consumer) and leave it ``False`` on intermediate edits.
1635
+ """
1636
+ return SendResult(success=False, error="Not supported")
1637
+
1638
+ async def delete_message(
1639
+ self,
1640
+ chat_id: str,
1641
+ message_id: str,
1642
+ ) -> bool:
1643
+ """
1644
+ Delete a previously sent message. Optional — platforms that don't
1645
+ support deletion return ``False`` and callers fall back to leaving
1646
+ the message in place.
1647
+
1648
+ Used by the stream consumer's fresh-final cleanup path (see
1649
+ openclaw/openclaw#72038) to remove long-lived preview messages
1650
+ after sending the completed reply as a fresh message so the
1651
+ platform's visible timestamp reflects completion time.
1652
+
1653
+ Returns ``True`` on successful deletion, ``False`` otherwise.
1654
+ Subclasses should override for platforms with a deletion API
1655
+ (e.g. Telegram ``deleteMessage``).
1656
+ """
1657
+ return False
1658
+
1659
+ def _get_ephemeral_system_ttl_default(self) -> int:
1660
+ """Read ``display.ephemeral_system_ttl`` from config.
1661
+
1662
+ Returns the TTL in seconds to use when an :class:`EphemeralReply`
1663
+ does not specify one explicitly. ``0`` (the default) disables
1664
+ auto-deletion. Non-fatal if config is unreadable.
1665
+ """
1666
+ try:
1667
+ from hermes_cli.config import load_config as _load_config
1668
+ except Exception:
1669
+ return 0
1670
+ try:
1671
+ cfg = _load_config()
1672
+ except Exception:
1673
+ return 0
1674
+ display = cfg.get("display", {}) if isinstance(cfg, dict) else {}
1675
+ if not isinstance(display, dict):
1676
+ return 0
1677
+ raw = display.get("ephemeral_system_ttl", 0)
1678
+ try:
1679
+ return int(raw)
1680
+ except (TypeError, ValueError):
1681
+ return 0
1682
+
1683
+ def _schedule_ephemeral_delete(
1684
+ self,
1685
+ chat_id: str,
1686
+ message_id: str,
1687
+ ttl_seconds: int,
1688
+ ) -> None:
1689
+ """Spawn a detached task that deletes ``message_id`` after ``ttl_seconds``.
1690
+
1691
+ Best-effort — failures (gateway restart, permission denied, message
1692
+ too old for Telegram's 48h window) are swallowed at debug level.
1693
+ Does not block the caller.
1694
+ """
1695
+
1696
+ async def _run_delete() -> None:
1697
+ try:
1698
+ await asyncio.sleep(max(1, int(ttl_seconds)))
1699
+ await self.delete_message(chat_id=chat_id, message_id=message_id)
1700
+ except asyncio.CancelledError:
1701
+ raise
1702
+ except Exception as e:
1703
+ logger.debug(
1704
+ "[%s] Ephemeral delete failed for %s/%s: %s",
1705
+ self.name, chat_id, message_id, e,
1706
+ )
1707
+
1708
+ coro = _run_delete()
1709
+ try:
1710
+ asyncio.create_task(coro)
1711
+ except RuntimeError:
1712
+ # No running loop (e.g. unit tests that never reach the async
1713
+ # path). Close the coroutine cleanly so Python doesn't warn
1714
+ # about it never being awaited, then drop silently.
1715
+ coro.close()
1716
+
1717
+ async def send_slash_confirm(
1718
+ self,
1719
+ chat_id: str,
1720
+ title: str,
1721
+ message: str,
1722
+ session_key: str,
1723
+ confirm_id: str,
1724
+ metadata: Optional[Dict[str, Any]] = None,
1725
+ ) -> SendResult:
1726
+ """Send a three-option slash-command confirmation prompt.
1727
+
1728
+ Used by the gateway's generic slash-confirm primitive (see
1729
+ ``GatewayRunner._request_slash_confirm``) for commands that have a
1730
+ non-destructive but expensive side effect the user should explicitly
1731
+ acknowledge — the current caller is ``/reload-mcp``, which
1732
+ invalidates the provider prompt cache.
1733
+
1734
+ Platforms with inline-button support (Telegram, Discord, Slack,
1735
+ Matrix, Feishu) should override this to render three buttons:
1736
+ Approve Once / Always Approve / Cancel. Button callbacks MUST be
1737
+ routed back through the gateway by calling
1738
+ ``GatewayRunner._resolve_slash_confirm(confirm_id, choice)`` where
1739
+ ``choice`` is ``"once"`` / ``"always"`` / ``"cancel"``.
1740
+
1741
+ Platforms without button UIs leave this as the default and fall
1742
+ through to the gateway's text fallback (which sends ``message`` as
1743
+ plain text and intercepts the next ``/approve`` / ``/always`` /
1744
+ ``/cancel`` reply).
1745
+
1746
+ ``confirm_id`` is a short string generated by the gateway; the
1747
+ adapter stores it alongside any platform-specific state needed to
1748
+ route the callback (e.g. Telegram's ``_approval_state`` dict).
1749
+ """
1750
+ return SendResult(success=False, error="Not supported")
1751
+
1752
+ async def send_clarify(
1753
+ self,
1754
+ chat_id: str,
1755
+ question: str,
1756
+ choices: Optional[list],
1757
+ clarify_id: str,
1758
+ session_key: str,
1759
+ metadata: Optional[Dict[str, Any]] = None,
1760
+ ) -> SendResult:
1761
+ """Send a clarify prompt to the user.
1762
+
1763
+ Two render modes:
1764
+
1765
+ * **Multiple choice** (``choices`` is a non-empty list) — adapters
1766
+ that override this should render inline buttons (one per choice
1767
+ plus a final "Other" / free-text option). Button callbacks
1768
+ MUST resolve via
1769
+ ``tools.clarify_gateway.resolve_gateway_clarify(clarify_id, response)``
1770
+ with the chosen string. Picking the "Other" button calls
1771
+ ``mark_awaiting_text(clarify_id)`` so the next message in the
1772
+ session is captured as the response.
1773
+
1774
+ * **Open-ended** (``choices`` is None or empty) — render the
1775
+ question as a plain text message; the next user message in the
1776
+ session is captured by the gateway's text-intercept and
1777
+ resolves the clarify automatically (see
1778
+ ``GatewayRunner._maybe_intercept_clarify_text``).
1779
+
1780
+ The default implementation falls back to a numbered text list,
1781
+ which works on every platform — the user replies with a number
1782
+ ("2") or with the literal choice text, and the gateway intercepts
1783
+ and resolves. For the text fallback path, the default calls
1784
+ ``mark_awaiting_text()`` so that the gateway text-intercept
1785
+ (:meth:`GatewayRunner._maybe_intercept_clarify_text`) catches the
1786
+ user's reply instead of timing out.
1787
+ Adapters with native button UIs (Telegram, Discord) SHOULD
1788
+ override this for a richer UX.
1789
+ """
1790
+ if choices:
1791
+ lines = [f"❓ {question}", ""]
1792
+ for i, choice in enumerate(choices, start=1):
1793
+ lines.append(f" {i}. {choice}")
1794
+ lines.append("")
1795
+ lines.append("Reply with the number, the option text, or your own answer.")
1796
+ text = "\n".join(lines)
1797
+ # Text fallback: enable text-capture so the gateway intercept
1798
+ # picks up the user's typed reply (e.g. "2" or choice text).
1799
+ from tools.clarify_gateway import mark_awaiting_text
1800
+ mark_awaiting_text(clarify_id)
1801
+ else:
1802
+ text = f"❓ {question}"
1803
+ return await self.send(
1804
+ chat_id=chat_id,
1805
+ content=text,
1806
+ metadata=metadata,
1807
+ )
1808
+
1809
+ async def send_private_notice(
1810
+ self,
1811
+ chat_id: str,
1812
+ user_id: Optional[str],
1813
+ content: str,
1814
+ reply_to: Optional[str] = None,
1815
+ metadata: Optional[Dict[str, Any]] = None,
1816
+ ) -> SendResult:
1817
+ """Send a notice privately when the platform supports it.
1818
+
1819
+ The default implementation falls back to a normal send so callers can
1820
+ use one code path across platforms.
1821
+ """
1822
+ return await self.send(
1823
+ chat_id=chat_id,
1824
+ content=content,
1825
+ reply_to=reply_to,
1826
+ metadata=metadata,
1827
+ )
1828
+
1829
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
1830
+ """
1831
+ Send a typing indicator.
1832
+
1833
+ Override in subclasses if the platform supports it.
1834
+ metadata: optional dict with platform-specific context (e.g. thread_id for Slack).
1835
+ """
1836
+ pass
1837
+
1838
+ async def stop_typing(self, chat_id: str) -> None:
1839
+ """Stop a persistent typing indicator (if the platform uses one).
1840
+
1841
+ Override in subclasses that start background typing loops.
1842
+ Default is a no-op for platforms with one-shot typing indicators.
1843
+ """
1844
+ pass
1845
+
1846
+ async def send_multiple_images(
1847
+ self,
1848
+ chat_id: str,
1849
+ images: List[Tuple[str, str]],
1850
+ metadata: Optional[Dict[str, Any]] = None,
1851
+ human_delay: float = 0.0,
1852
+ ) -> None:
1853
+ """Send a batch of images.
1854
+
1855
+ Accepts ``http(s)://``, ``file://`` URIs in the first tuple
1856
+ element.
1857
+
1858
+ Default implementation sends each item individually,
1859
+ routing animated GIFs through ``send_animation`` and local
1860
+ files through ``send_image_file``.
1861
+
1862
+ Override in subclasses to bundle into a single native API call
1863
+ (e.g. Signal's multi-attachment RPC)
1864
+ """
1865
+ from urllib.parse import unquote as _unquote
1866
+
1867
+ for image_url, alt_text in images:
1868
+ if human_delay > 0:
1869
+ await asyncio.sleep(human_delay)
1870
+ try:
1871
+ logger.info(
1872
+ "[%s] Sending image: %s (alt=%s)",
1873
+ self.name,
1874
+ safe_url_for_log(image_url),
1875
+ alt_text[:30] if alt_text else "",
1876
+ )
1877
+ if image_url.startswith("file://"):
1878
+ img_result = await self.send_image_file(
1879
+ chat_id=chat_id,
1880
+ image_path=_unquote(image_url[7:]),
1881
+ caption=alt_text if alt_text else None,
1882
+ metadata=metadata,
1883
+ )
1884
+ elif self._is_animation_url(image_url):
1885
+ img_result = await self.send_animation(
1886
+ chat_id=chat_id,
1887
+ animation_url=image_url,
1888
+ caption=alt_text if alt_text else None,
1889
+ metadata=metadata,
1890
+ )
1891
+ else:
1892
+ img_result = await self.send_image(
1893
+ chat_id=chat_id,
1894
+ image_url=image_url,
1895
+ caption=alt_text if alt_text else None,
1896
+ metadata=metadata,
1897
+ )
1898
+ if not img_result.success:
1899
+ logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
1900
+ except Exception as img_err:
1901
+ logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
1902
+
1903
+ async def send_image(
1904
+ self,
1905
+ chat_id: str,
1906
+ image_url: str,
1907
+ caption: Optional[str] = None,
1908
+ reply_to: Optional[str] = None,
1909
+ metadata: Optional[Dict[str, Any]] = None,
1910
+ ) -> SendResult:
1911
+ """
1912
+ Send an image natively via the platform API.
1913
+
1914
+ Override in subclasses to send images as proper attachments
1915
+ instead of plain-text URLs. Default falls back to sending the
1916
+ URL as a text message.
1917
+ """
1918
+ # Fallback: send URL as text (subclasses override for native images)
1919
+ text = f"{caption}\n{image_url}" if caption else image_url
1920
+ return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
1921
+
1922
+ async def send_animation(
1923
+ self,
1924
+ chat_id: str,
1925
+ animation_url: str,
1926
+ caption: Optional[str] = None,
1927
+ reply_to: Optional[str] = None,
1928
+ metadata: Optional[Dict[str, Any]] = None,
1929
+ ) -> SendResult:
1930
+ """
1931
+ Send an animated GIF natively via the platform API.
1932
+
1933
+ Override in subclasses to send GIFs as proper animations
1934
+ (e.g., Telegram send_animation) so they auto-play inline.
1935
+ Default falls back to send_image.
1936
+ """
1937
+ return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to, metadata=metadata)
1938
+
1939
+ @staticmethod
1940
+ def _is_animation_url(url: str) -> bool:
1941
+ """Check if a URL points to an animated GIF (vs a static image)."""
1942
+ lower = url.lower().split('?')[0] # Strip query params
1943
+ return lower.endswith('.gif')
1944
+
1945
+ @staticmethod
1946
+ def extract_images(content: str) -> Tuple[List[Tuple[str, str]], str]:
1947
+ """
1948
+ Extract image URLs from markdown and HTML image tags in a response.
1949
+
1950
+ Finds patterns like:
1951
+ - ![alt text](https://example.com/image.png)
1952
+ - <img src="https://example.com/image.png">
1953
+ - <img src="https://example.com/image.png"></img>
1954
+
1955
+ Args:
1956
+ content: The response text to scan.
1957
+
1958
+ Returns:
1959
+ Tuple of (list of (url, alt_text) pairs, cleaned content with image tags removed).
1960
+ """
1961
+ images = []
1962
+ cleaned = content
1963
+
1964
+ # Match markdown images: ![alt](url)
1965
+ md_pattern = r'!\[([^\]]*)\]\((https?://[^\s\)]+)\)'
1966
+ for match in re.finditer(md_pattern, content):
1967
+ alt_text = match.group(1)
1968
+ url = match.group(2)
1969
+ # Only extract URLs that look like actual images
1970
+ if any(url.lower().endswith(ext) or ext in url.lower() for ext in
1971
+ ['.png', '.jpg', '.jpeg', '.gif', '.webp', 'fal.media', 'fal-cdn', 'replicate.delivery']):
1972
+ images.append((url, alt_text))
1973
+
1974
+ # Match HTML img tags: <img src="url"> or <img src="url"></img> or <img src="url"/>
1975
+ html_pattern = r'<img\s+src=["\']?(https?://[^\s"\'<>]+)["\']?\s*/?>\s*(?:</img>)?'
1976
+ for match in re.finditer(html_pattern, content):
1977
+ url = match.group(1)
1978
+ images.append((url, ""))
1979
+
1980
+ # Remove only the matched image tags from content (not all markdown images)
1981
+ if images:
1982
+ extracted_urls = {url for url, _ in images}
1983
+ def _remove_if_extracted(match):
1984
+ url = match.group(2) if match.lastindex >= 2 else match.group(1)
1985
+ return '' if url in extracted_urls else match.group(0)
1986
+ cleaned = re.sub(md_pattern, _remove_if_extracted, cleaned)
1987
+ cleaned = re.sub(html_pattern, _remove_if_extracted, cleaned)
1988
+ # Clean up leftover blank lines
1989
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
1990
+
1991
+ return images, cleaned
1992
+
1993
+ async def send_voice(
1994
+ self,
1995
+ chat_id: str,
1996
+ audio_path: str,
1997
+ caption: Optional[str] = None,
1998
+ reply_to: Optional[str] = None,
1999
+ metadata: Optional[Dict[str, Any]] = None,
2000
+ **kwargs,
2001
+ ) -> SendResult:
2002
+ """
2003
+ Send an audio file as a native voice message via the platform API.
2004
+
2005
+ Override in subclasses to send audio as voice bubbles (Telegram)
2006
+ or file attachments (Discord). Default falls back to sending the
2007
+ file path as text.
2008
+ """
2009
+ text = f"🔊 Audio: {audio_path}"
2010
+ if caption:
2011
+ text = f"{caption}\n{text}"
2012
+ return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
2013
+
2014
+ async def play_tts(
2015
+ self,
2016
+ chat_id: str,
2017
+ audio_path: str,
2018
+ **kwargs,
2019
+ ) -> SendResult:
2020
+ """
2021
+ Play auto-TTS audio for voice replies.
2022
+
2023
+ Override in subclasses for invisible playback (e.g. Web UI).
2024
+ Default falls back to send_voice (shows audio player).
2025
+ """
2026
+ return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)
2027
+
2028
+ async def send_video(
2029
+ self,
2030
+ chat_id: str,
2031
+ video_path: str,
2032
+ caption: Optional[str] = None,
2033
+ reply_to: Optional[str] = None,
2034
+ metadata: Optional[Dict[str, Any]] = None,
2035
+ **kwargs,
2036
+ ) -> SendResult:
2037
+ """
2038
+ Send a video natively via the platform API.
2039
+
2040
+ Override in subclasses to send videos as inline playable media.
2041
+ Default falls back to sending the file path as text.
2042
+ """
2043
+ text = f"🎬 Video: {video_path}"
2044
+ if caption:
2045
+ text = f"{caption}\n{text}"
2046
+ return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
2047
+
2048
+ async def send_document(
2049
+ self,
2050
+ chat_id: str,
2051
+ file_path: str,
2052
+ caption: Optional[str] = None,
2053
+ file_name: Optional[str] = None,
2054
+ reply_to: Optional[str] = None,
2055
+ metadata: Optional[Dict[str, Any]] = None,
2056
+ **kwargs,
2057
+ ) -> SendResult:
2058
+ """
2059
+ Send a document/file natively via the platform API.
2060
+
2061
+ Override in subclasses to send files as downloadable attachments.
2062
+ Default falls back to sending the file path as text.
2063
+ """
2064
+ text = f"📎 File: {file_path}"
2065
+ if caption:
2066
+ text = f"{caption}\n{text}"
2067
+ return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
2068
+
2069
+ async def send_image_file(
2070
+ self,
2071
+ chat_id: str,
2072
+ image_path: str,
2073
+ caption: Optional[str] = None,
2074
+ reply_to: Optional[str] = None,
2075
+ metadata: Optional[Dict[str, Any]] = None,
2076
+ **kwargs,
2077
+ ) -> SendResult:
2078
+ """
2079
+ Send a local image file natively via the platform API.
2080
+
2081
+ Unlike send_image() which takes a URL, this takes a local file path.
2082
+ Override in subclasses for native photo attachments.
2083
+ Default falls back to sending the file path as text.
2084
+ """
2085
+ text = f"🖼️ Image: {image_path}"
2086
+ if caption:
2087
+ text = f"{caption}\n{text}"
2088
+ return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
2089
+
2090
+ @staticmethod
2091
+ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
2092
+ """
2093
+ Extract MEDIA:<path> tags and [[audio_as_voice]] directives from response text.
2094
+
2095
+ The TTS tool returns responses like:
2096
+ [[audio_as_voice]]
2097
+ MEDIA:/path/to/audio.ogg
2098
+
2099
+ Skills that produce large/lossless images (e.g. info-graph, where a
2100
+ rendered JPG is 1-2 MB but Telegram's sendPhoto recompresses to
2101
+ ~200 KB at 1280px) can use ``[[as_document]]`` to request unmodified
2102
+ delivery via sendDocument instead of sendPhoto/sendMediaGroup. The
2103
+ directive is detected at the dispatch sites (which have access to the
2104
+ original response); this method just strips it so it never leaks into
2105
+ user-visible text. Per-file granularity is intentionally not exposed —
2106
+ when an agent emits ``[[as_document]]`` once, every image path in the
2107
+ same response is delivered as a document, mirroring the all-or-nothing
2108
+ scope of ``[[audio_as_voice]]``.
2109
+
2110
+ Args:
2111
+ content: The response text to scan.
2112
+
2113
+ Returns:
2114
+ Tuple of (list of (path, is_voice) pairs, cleaned content with tags removed).
2115
+ """
2116
+ media = []
2117
+ cleaned = content
2118
+
2119
+ # Check for [[audio_as_voice]] directive
2120
+ has_voice_tag = "[[audio_as_voice]]" in content
2121
+ cleaned = cleaned.replace("[[audio_as_voice]]", "")
2122
+ # Strip [[as_document]] directive — callers inspect the original
2123
+ # ``content`` for it (so they can still react to it); here we just
2124
+ # keep it out of the user-visible cleaned text.
2125
+ cleaned = cleaned.replace("[[as_document]]", "")
2126
+
2127
+ # Extract MEDIA:<path> tags, allowing optional whitespace after the colon
2128
+ # and quoted/backticked paths for LLM-formatted outputs.
2129
+ media_pattern = re.compile(
2130
+ r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
2131
+ )
2132
+ for match in media_pattern.finditer(content):
2133
+ path = match.group("path").strip()
2134
+ if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
2135
+ path = path[1:-1].strip()
2136
+ path = path.lstrip("`\"'").rstrip("`\"',.;:)}]")
2137
+ if path:
2138
+ media.append((os.path.expanduser(path), has_voice_tag))
2139
+
2140
+ # Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
2141
+ if media:
2142
+ cleaned = media_pattern.sub('', cleaned)
2143
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
2144
+
2145
+ return media, cleaned
2146
+
2147
+ @staticmethod
2148
+ def extract_local_files(content: str) -> Tuple[List[str], str]:
2149
+ """
2150
+ Detect bare local file paths in response text for native media delivery.
2151
+
2152
+ Matches absolute paths (/...) and tilde paths (~/) ending in common
2153
+ image or video extensions. Validates each candidate with
2154
+ ``os.path.isfile()`` to avoid false positives from URLs or
2155
+ non-existent paths.
2156
+
2157
+ Paths inside fenced code blocks (``` ... ```) and inline code
2158
+ (`...`) are ignored so that code samples are never mutilated.
2159
+
2160
+ Returns:
2161
+ Tuple of (list of expanded file paths, cleaned text with the
2162
+ raw path strings removed).
2163
+ """
2164
+ _LOCAL_MEDIA_EXTS = (
2165
+ '.png', '.jpg', '.jpeg', '.gif', '.webp',
2166
+ '.mp4', '.mov', '.avi', '.mkv', '.webm',
2167
+ )
2168
+ ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
2169
+
2170
+ # (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
2171
+ # and relative paths (./foo.png)
2172
+ # (?:~/|/) anchors to absolute or home-relative paths
2173
+ path_re = re.compile(
2174
+ r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
2175
+ re.IGNORECASE,
2176
+ )
2177
+
2178
+ # Build spans covered by fenced code blocks and inline code
2179
+ code_spans: list = []
2180
+ for m in re.finditer(r'```[^\n]*\n.*?```', content, re.DOTALL):
2181
+ code_spans.append((m.start(), m.end()))
2182
+ for m in re.finditer(r'`[^`\n]+`', content):
2183
+ code_spans.append((m.start(), m.end()))
2184
+
2185
+ def _in_code(pos: int) -> bool:
2186
+ return any(s <= pos < e for s, e in code_spans)
2187
+
2188
+ found: list = [] # (raw_match_text, expanded_path)
2189
+ for match in path_re.finditer(content):
2190
+ if _in_code(match.start()):
2191
+ continue
2192
+ raw = match.group(0)
2193
+ expanded = os.path.expanduser(raw)
2194
+ if os.path.isfile(expanded):
2195
+ found.append((raw, expanded))
2196
+
2197
+ # Deduplicate by expanded path, preserving discovery order
2198
+ seen: set = set()
2199
+ unique: list = []
2200
+ for raw, expanded in found:
2201
+ if expanded not in seen:
2202
+ seen.add(expanded)
2203
+ unique.append((raw, expanded))
2204
+
2205
+ paths = [expanded for _, expanded in unique]
2206
+
2207
+ cleaned = content
2208
+ if unique:
2209
+ for raw, _exp in unique:
2210
+ cleaned = cleaned.replace(raw, '')
2211
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
2212
+
2213
+ return paths, cleaned
2214
+
2215
+ async def _keep_typing(
2216
+ self,
2217
+ chat_id: str,
2218
+ interval: float = 2.0,
2219
+ metadata=None,
2220
+ stop_event: asyncio.Event | None = None,
2221
+ ) -> None:
2222
+ """
2223
+ Continuously send typing indicator until cancelled.
2224
+
2225
+ Telegram/Discord typing status expires after ~5 seconds, so we refresh every 2
2226
+ to recover quickly after progress messages interrupt it.
2227
+
2228
+ Skips send_typing when the chat is in ``_typing_paused`` (e.g. while
2229
+ the agent is waiting for dangerous-command approval). This is critical
2230
+ for Slack's Assistant API where ``assistant_threads_setStatus`` disables
2231
+ the compose box — pausing lets the user type ``/approve`` or ``/deny``.
2232
+
2233
+ Each ``send_typing`` call is bounded by a ~1.5s timeout so a slow
2234
+ network round-trip can't stall the refresh cadence. Telegram- and
2235
+ Discord-side typing expire after ~5s; if any individual send_typing
2236
+ takes longer than the refresh interval, the bubble would die and
2237
+ stay dead until that call returns. Abandoning the slow call lets
2238
+ the next tick fire a fresh send_typing on schedule — as long as
2239
+ one of them succeeds within the 5s platform-side window, the bubble
2240
+ stays visible across provider stalls / upstream API timeouts.
2241
+ """
2242
+ # Bound each send_typing round-trip so the refresh cadence isn't
2243
+ # gated on network health. Must stay below ``interval`` so a slow
2244
+ # call gets abandoned before the next scheduled tick.
2245
+ _send_typing_timeout = max(0.25, min(1.5, interval - 0.25))
2246
+ try:
2247
+ while True:
2248
+ if stop_event is not None and stop_event.is_set():
2249
+ return
2250
+ if chat_id not in self._typing_paused:
2251
+ try:
2252
+ await asyncio.wait_for(
2253
+ self.send_typing(chat_id, metadata=metadata),
2254
+ timeout=_send_typing_timeout,
2255
+ )
2256
+ except asyncio.TimeoutError:
2257
+ # Slow network — abandon this tick, keep the loop
2258
+ # on schedule so the next send_typing fires fresh.
2259
+ pass
2260
+ except asyncio.CancelledError:
2261
+ raise
2262
+ except Exception as typing_err:
2263
+ logger.debug(
2264
+ "[%s] send_typing error (non-fatal): %s",
2265
+ self.name, typing_err,
2266
+ )
2267
+ if stop_event is None:
2268
+ await asyncio.sleep(interval)
2269
+ continue
2270
+ loop = asyncio.get_running_loop()
2271
+ deadline = loop.time() + interval
2272
+ while not stop_event.is_set():
2273
+ remaining = deadline - loop.time()
2274
+ if remaining <= 0:
2275
+ break
2276
+ # Poll instead of wait_for(stop_event.wait()). Cancelling
2277
+ # wait_for while it owns the inner Event.wait task can leave
2278
+ # shutdown paths stuck awaiting the typing task on Python
2279
+ # 3.11/pytest-asyncio; sleep cancellation is immediate.
2280
+ await asyncio.sleep(min(0.25, remaining))
2281
+ if stop_event.is_set():
2282
+ return
2283
+ except asyncio.CancelledError:
2284
+ pass # Normal cancellation when handler completes
2285
+ finally:
2286
+ # Ensure the underlying platform typing loop is stopped.
2287
+ # _keep_typing may have called send_typing() after an outer
2288
+ # stop_typing() cleared the task dict, recreating the loop.
2289
+ # Cancelling _keep_typing alone won't clean that up.
2290
+ if hasattr(self, "stop_typing"):
2291
+ try:
2292
+ await self.stop_typing(chat_id)
2293
+ except Exception:
2294
+ pass
2295
+ self._typing_paused.discard(chat_id)
2296
+
2297
+ def pause_typing_for_chat(self, chat_id: str) -> None:
2298
+ """Pause typing indicator for a chat (e.g. during approval waits).
2299
+
2300
+ Thread-safe (CPython GIL) — can be called from the sync agent thread
2301
+ while ``_keep_typing`` runs on the async event loop.
2302
+ """
2303
+ self._typing_paused.add(chat_id)
2304
+
2305
+ def resume_typing_for_chat(self, chat_id: str) -> None:
2306
+ """Resume typing indicator for a chat after approval resolves."""
2307
+ self._typing_paused.discard(chat_id)
2308
+
2309
+ async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:
2310
+ """Signal the active session loop to stop and clear typing immediately."""
2311
+ if session_key:
2312
+ interrupt_event = self._active_sessions.get(session_key)
2313
+ if interrupt_event is not None:
2314
+ interrupt_event.set()
2315
+ try:
2316
+ await self.stop_typing(chat_id)
2317
+ except Exception:
2318
+ pass
2319
+
2320
+ def register_post_delivery_callback(
2321
+ self,
2322
+ session_key: str,
2323
+ callback: Callable,
2324
+ *,
2325
+ generation: int | None = None,
2326
+ ) -> None:
2327
+ """Register a deferred callback to fire after the main response.
2328
+
2329
+ ``generation`` lets callers tie the callback to a specific gateway run
2330
+ generation so stale runs cannot clear callbacks owned by a fresher run.
2331
+
2332
+ If a callback for the same ``session_key`` (and generation, when set)
2333
+ is already registered, the new callback is chained — both fire, in
2334
+ registration order, with per-callback exception isolation. This lets
2335
+ independent features (background-review release + temporary-bubble
2336
+ cleanup) coexist without clobbering each other. Stale-generation
2337
+ callers never overwrite a fresher generation's slot.
2338
+ """
2339
+ if not session_key or not callable(callback):
2340
+ return
2341
+
2342
+ existing = self._post_delivery_callbacks.get(session_key)
2343
+ if existing is not None:
2344
+ if isinstance(existing, tuple) and len(existing) == 2:
2345
+ existing_gen, existing_cb = existing
2346
+ else:
2347
+ existing_gen, existing_cb = None, existing
2348
+ # Stale-generation registrations never overwrite a fresher slot.
2349
+ if (
2350
+ existing_gen is not None
2351
+ and generation is not None
2352
+ and int(generation) < int(existing_gen)
2353
+ ):
2354
+ return
2355
+ # Same-or-newer generation: chain with the existing callback so
2356
+ # both fire in registration order.
2357
+ if callable(existing_cb) and (
2358
+ existing_gen is None
2359
+ or generation is None
2360
+ or int(existing_gen) == int(generation)
2361
+ ):
2362
+ _prev = existing_cb
2363
+ _new = callback
2364
+
2365
+ def _chained() -> None:
2366
+ try:
2367
+ _prev()
2368
+ except Exception:
2369
+ logger.debug("Post-delivery callback failed", exc_info=True)
2370
+ try:
2371
+ _new()
2372
+ except Exception:
2373
+ logger.debug("Post-delivery callback failed", exc_info=True)
2374
+
2375
+ callback = _chained
2376
+
2377
+ if generation is None:
2378
+ self._post_delivery_callbacks[session_key] = callback
2379
+ else:
2380
+ self._post_delivery_callbacks[session_key] = (int(generation), callback)
2381
+
2382
+ def pop_post_delivery_callback(
2383
+ self,
2384
+ session_key: str,
2385
+ *,
2386
+ generation: int | None = None,
2387
+ ) -> Callable | None:
2388
+ """Pop a deferred callback, optionally requiring generation ownership."""
2389
+ if not session_key:
2390
+ return None
2391
+ entry = self._post_delivery_callbacks.get(session_key)
2392
+ if entry is None:
2393
+ return None
2394
+ if isinstance(entry, tuple) and len(entry) == 2:
2395
+ entry_generation, callback = entry
2396
+ if generation is not None and int(entry_generation) != int(generation):
2397
+ return None
2398
+ self._post_delivery_callbacks.pop(session_key, None)
2399
+ return callback if callable(callback) else None
2400
+ if generation is not None:
2401
+ return None
2402
+ self._post_delivery_callbacks.pop(session_key, None)
2403
+ return entry if callable(entry) else None
2404
+
2405
+ # ── Processing lifecycle hooks ──────────────────────────────────────────
2406
+ # Subclasses override these to react to message processing events
2407
+ # (e.g. Discord adds 👀/✅/❌ reactions).
2408
+
2409
+ async def on_processing_start(self, event: MessageEvent) -> None:
2410
+ """Hook called when background processing begins."""
2411
+
2412
+ async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
2413
+ """Hook called when background processing completes."""
2414
+
2415
+ async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
2416
+ """Run a lifecycle hook without letting failures break message flow."""
2417
+ hook = getattr(self, hook_name, None)
2418
+ if not callable(hook):
2419
+ return
2420
+ try:
2421
+ await hook(*args, **kwargs)
2422
+ except Exception as e:
2423
+ logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e)
2424
+
2425
+ @staticmethod
2426
+ def _is_retryable_error(error: Optional[str]) -> bool:
2427
+ """Return True if the error string looks like a transient network failure."""
2428
+ if not error:
2429
+ return False
2430
+ lowered = error.lower()
2431
+ return any(pat in lowered for pat in _RETRYABLE_ERROR_PATTERNS)
2432
+
2433
+ @staticmethod
2434
+ def _is_timeout_error(error: Optional[str]) -> bool:
2435
+ """Return True if the error string indicates a read/write timeout.
2436
+
2437
+ Timeout errors are NOT retryable and should NOT trigger plain-text
2438
+ fallback — the request may have already been delivered.
2439
+ """
2440
+ if not error:
2441
+ return False
2442
+ lowered = error.lower()
2443
+ return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered
2444
+
2445
+ def _unwrap_ephemeral(self, response: Any) -> Tuple[Optional[str], int]:
2446
+ """Unwrap a handler response into (text, ttl_seconds).
2447
+
2448
+ Accepts a plain string, ``None``, or an :class:`EphemeralReply`.
2449
+ Returns ``(text, ttl)`` where ``ttl > 0`` means the caller should
2450
+ schedule a deletion via :meth:`_schedule_ephemeral_delete` after
2451
+ the send succeeds. ``ttl`` is forced to 0 when the adapter
2452
+ doesn't override :meth:`delete_message` so non-supporting
2453
+ platforms silently degrade to normal sends.
2454
+ """
2455
+ if isinstance(response, EphemeralReply):
2456
+ ttl = response.ttl_seconds
2457
+ if ttl is None:
2458
+ try:
2459
+ ttl = int(self._get_ephemeral_system_ttl_default())
2460
+ except Exception:
2461
+ ttl = 0
2462
+ if ttl and ttl > 0 and type(self).delete_message is BasePlatformAdapter.delete_message:
2463
+ ttl = 0
2464
+ return response.text, int(ttl or 0)
2465
+ return response, 0
2466
+
2467
+ async def _send_with_retry(
2468
+ self,
2469
+ chat_id: str,
2470
+ content: str,
2471
+ reply_to: Optional[str] = None,
2472
+ metadata: Any = None,
2473
+ max_retries: int = 2,
2474
+ base_delay: float = 2.0,
2475
+ ) -> "SendResult":
2476
+ """
2477
+ Send a message with automatic retry for transient network errors.
2478
+
2479
+ On permanent failures (e.g. formatting / permission errors) falls back
2480
+ to a plain-text version before giving up. If all attempts fail due to
2481
+ network errors, sends the user a brief delivery-failure notice so they
2482
+ know to retry rather than waiting indefinitely.
2483
+ """
2484
+
2485
+ result = await self.send(
2486
+ chat_id=chat_id,
2487
+ content=content,
2488
+ reply_to=reply_to,
2489
+ metadata=metadata,
2490
+ )
2491
+
2492
+ if result.success:
2493
+ return result
2494
+
2495
+ error_str = result.error or ""
2496
+ is_network = result.retryable or self._is_retryable_error(error_str)
2497
+
2498
+ # Timeout errors are not safe to retry (message may have been
2499
+ # delivered) and not formatting errors — return the failure as-is.
2500
+ if not is_network and self._is_timeout_error(error_str):
2501
+ return result
2502
+
2503
+ if is_network:
2504
+ # Retry with exponential backoff for transient errors
2505
+ for attempt in range(1, max_retries + 1):
2506
+ delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)
2507
+ logger.warning(
2508
+ "[%s] Send failed (attempt %d/%d, retrying in %.1fs): %s",
2509
+ self.name, attempt, max_retries, delay, error_str,
2510
+ )
2511
+ await asyncio.sleep(delay)
2512
+ result = await self.send(
2513
+ chat_id=chat_id,
2514
+ content=content,
2515
+ reply_to=reply_to,
2516
+ metadata=metadata,
2517
+ )
2518
+ if result.success:
2519
+ logger.info("[%s] Send succeeded on retry %d", self.name, attempt)
2520
+ return result
2521
+ error_str = result.error or ""
2522
+ if not (result.retryable or self._is_retryable_error(error_str)):
2523
+ break # error switched to non-transient — fall through to plain-text fallback
2524
+ else:
2525
+ # All retries exhausted (loop completed without break) — notify user
2526
+ logger.error("[%s] Failed to deliver response after %d retries: %s", self.name, max_retries, error_str)
2527
+ notice = (
2528
+ "\u26a0\ufe0f Message delivery failed after multiple attempts. "
2529
+ "Please try again \u2014 your request was processed but the response could not be sent."
2530
+ )
2531
+ try:
2532
+ await self.send(chat_id=chat_id, content=notice, reply_to=reply_to, metadata=metadata)
2533
+ except Exception as notify_err:
2534
+ logger.debug("[%s] Could not send delivery-failure notice: %s", self.name, notify_err)
2535
+ return result
2536
+
2537
+ # Non-network / post-retry formatting failure: try plain text as fallback
2538
+ logger.warning("[%s] Send failed: %s — trying plain-text fallback", self.name, error_str)
2539
+ fallback_result = await self.send(
2540
+ chat_id=chat_id,
2541
+ content=f"(Response formatting failed, plain text:)\n\n{content[:3500]}",
2542
+ reply_to=reply_to,
2543
+ metadata=metadata,
2544
+ )
2545
+ if not fallback_result.success:
2546
+ logger.error("[%s] Fallback send also failed: %s", self.name, fallback_result.error)
2547
+ return fallback_result
2548
+
2549
+ @staticmethod
2550
+ def _merge_caption(existing_text: Optional[str], new_text: str) -> str:
2551
+ """Merge a new caption into existing text, avoiding duplicates.
2552
+
2553
+ Uses line-by-line exact match (not substring) to prevent false positives
2554
+ where a shorter caption is silently dropped because it appears as a
2555
+ substring of a longer one (e.g. "Meeting" inside "Meeting agenda").
2556
+ Whitespace is normalised for comparison.
2557
+ """
2558
+ if not existing_text:
2559
+ return new_text
2560
+ existing_captions = [c.strip() for c in existing_text.split("\n\n")]
2561
+ if new_text.strip() not in existing_captions:
2562
+ return f"{existing_text}\n\n{new_text}".strip()
2563
+ return existing_text
2564
+
2565
+ # ------------------------------------------------------------------
2566
+ # Session task + guard ownership helpers
2567
+ # ------------------------------------------------------------------
2568
+ # These were introduced together with the _session_tasks owner map to
2569
+ # make session lifecycle reconciliation deterministic across (a) the
2570
+ # normal completion path, (b) /stop/ /new/ /reset bypass commands,
2571
+ # and (c) stale-lock self-heal on the next inbound message.
2572
+
2573
+ def _release_session_guard(
2574
+ self,
2575
+ session_key: str,
2576
+ *,
2577
+ guard: Optional[asyncio.Event] = None,
2578
+ ) -> None:
2579
+ """Release the adapter-level guard for a session.
2580
+
2581
+ When ``guard`` is provided, only release the entry if it still points
2582
+ at that exact Event. This lets reset-like commands swap in a temporary
2583
+ guard while the old processing task unwinds, without having the old
2584
+ task's cleanup accidentally clear the replacement guard.
2585
+ """
2586
+ current_guard = self._active_sessions.get(session_key)
2587
+ if current_guard is None:
2588
+ return
2589
+ if guard is not None and current_guard is not guard:
2590
+ return
2591
+ del self._active_sessions[session_key]
2592
+
2593
+ def _session_task_is_stale(self, session_key: str) -> bool:
2594
+ """Return True if the owner task for ``session_key`` is done/cancelled.
2595
+
2596
+ A lock is "stale" when the adapter still has ``_active_sessions[key]``
2597
+ AND a known owner task in ``_session_tasks`` that has already exited.
2598
+ When there is no owner task at all, that usually means the guard was
2599
+ installed by some path other than handle_message() (tests sometimes
2600
+ install guards directly) — don't treat that as stale. The on-entry
2601
+ self-heal only needs to handle the production split-brain case where
2602
+ an owner task was recorded, then exited without clearing its guard.
2603
+ """
2604
+ task = self._session_tasks.get(session_key)
2605
+ if task is None:
2606
+ return False
2607
+ done = getattr(task, "done", None)
2608
+ return bool(done and done())
2609
+
2610
+ def _heal_stale_session_lock(self, session_key: str) -> bool:
2611
+ """Clear a stale session lock if the owner task is already gone.
2612
+
2613
+ Returns True if a stale lock was healed. Returns False if there is
2614
+ no lock, or the owner task is still alive (the normal busy case).
2615
+
2616
+ This is the on-entry safety net sidbin's issue #11016 analysis calls
2617
+ for: without it, a split-brain — adapter still thinks the session is
2618
+ active, but nothing is actually processing — traps the chat in
2619
+ infinite "Interrupting current task..." until the gateway is
2620
+ restarted.
2621
+ """
2622
+ if session_key not in self._active_sessions:
2623
+ return False
2624
+ if not self._session_task_is_stale(session_key):
2625
+ return False
2626
+ logger.warning(
2627
+ "[%s] Healing stale session lock for %s (owner task is done/absent)",
2628
+ self.name,
2629
+ session_key,
2630
+ )
2631
+ self._active_sessions.pop(session_key, None)
2632
+ self._pending_messages.pop(session_key, None)
2633
+ self._session_tasks.pop(session_key, None)
2634
+ return True
2635
+
2636
+ def _start_session_processing(
2637
+ self,
2638
+ event: MessageEvent,
2639
+ session_key: str,
2640
+ *,
2641
+ interrupt_event: Optional[asyncio.Event] = None,
2642
+ ) -> bool:
2643
+ """Spawn a background processing task under the given session guard.
2644
+
2645
+ Returns True on success. If the runtime stubs ``create_task`` with a
2646
+ non-Task sentinel (some tests do this), the guard is rolled back and
2647
+ False is returned so the caller isn't left holding a half-installed
2648
+ session lock.
2649
+ """
2650
+ guard = interrupt_event or asyncio.Event()
2651
+ self._active_sessions[session_key] = guard
2652
+
2653
+ task = asyncio.create_task(self._process_message_background(event, session_key))
2654
+ self._session_tasks[session_key] = task
2655
+ try:
2656
+ self._background_tasks.add(task)
2657
+ except TypeError:
2658
+ # Tests stub create_task() with lightweight sentinels that are not
2659
+ # hashable and do not support lifecycle callbacks.
2660
+ self._session_tasks.pop(session_key, None)
2661
+ self._release_session_guard(session_key, guard=guard)
2662
+ return False
2663
+ if hasattr(task, "add_done_callback"):
2664
+ task.add_done_callback(self._background_tasks.discard)
2665
+ task.add_done_callback(self._expected_cancelled_tasks.discard)
2666
+ return True
2667
+
2668
+ async def cancel_session_processing(
2669
+ self,
2670
+ session_key: str,
2671
+ *,
2672
+ release_guard: bool = True,
2673
+ discard_pending: bool = True,
2674
+ ) -> None:
2675
+ """Cancel in-flight processing for a single session.
2676
+
2677
+ ``release_guard=False`` keeps the adapter-level session guard in place
2678
+ so reset-like commands can finish atomically before follow-up messages
2679
+ are allowed to start a fresh background task.
2680
+
2681
+ Bounded by a 5s timeout so a wedged finally block in the cancelled
2682
+ task (typing-task cleanup, on_processing_complete hook, etc.) can't
2683
+ stall the calling dispatch coroutine — particularly under pytest-
2684
+ asyncio where the event loop's cancellation-propagation semantics
2685
+ differ subtly from a bare ``asyncio.run`` harness.
2686
+ """
2687
+ task = self._session_tasks.pop(session_key, None)
2688
+ if task is not None and not task.done():
2689
+ logger.debug(
2690
+ "[%s] Cancelling active processing for session %s",
2691
+ self.name,
2692
+ session_key,
2693
+ )
2694
+ self._expected_cancelled_tasks.add(task)
2695
+ task.cancel()
2696
+ try:
2697
+ await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
2698
+ except asyncio.CancelledError:
2699
+ pass
2700
+ except asyncio.TimeoutError:
2701
+ logger.warning(
2702
+ "[%s] Cancelled task for %s did not exit within 5s; "
2703
+ "unblocking dispatch and letting the task unwind in the background",
2704
+ self.name, session_key,
2705
+ )
2706
+ except Exception:
2707
+ logger.debug(
2708
+ "[%s] Session cancellation raised while unwinding %s",
2709
+ self.name,
2710
+ session_key,
2711
+ exc_info=True,
2712
+ )
2713
+ if discard_pending:
2714
+ self._pending_messages.pop(session_key, None)
2715
+ if release_guard:
2716
+ self._release_session_guard(session_key)
2717
+
2718
+ async def _drain_pending_after_session_command(
2719
+ self,
2720
+ session_key: str,
2721
+ command_guard: asyncio.Event,
2722
+ ) -> None:
2723
+ """Resume the latest queued follow-up once a session command completes.
2724
+
2725
+ Called at the tail of /stop, /new, and /reset dispatch. Releases the
2726
+ command-scoped guard, then — if a follow-up message landed while the
2727
+ command was running — spawns a fresh processing task for it.
2728
+ """
2729
+ pending_event = self._pending_messages.pop(session_key, None)
2730
+ self._release_session_guard(session_key, guard=command_guard)
2731
+ if pending_event is None:
2732
+ return
2733
+ self._start_session_processing(pending_event, session_key)
2734
+
2735
+ async def _dispatch_active_session_command(
2736
+ self,
2737
+ event: MessageEvent,
2738
+ session_key: str,
2739
+ cmd: str,
2740
+ ) -> None:
2741
+ """Dispatch a reset-like bypass command while preserving guard ordering.
2742
+
2743
+ /stop, /new, and /reset must:
2744
+ 1. Keep the session guard installed while the runner processes the
2745
+ command (so a racing follow-up message stays queued, not
2746
+ dispatched as a second parallel run).
2747
+ 2. Cancel the old in-flight adapter task only AFTER the runner has
2748
+ finished handling the command (so the runner sees consistent
2749
+ state and its response is sent in order).
2750
+ 3. Release the command-scoped guard and drain the latest queued
2751
+ follow-up exactly once, after 1 and 2 complete.
2752
+ """
2753
+ logger.debug(
2754
+ "[%s] Command '/%s' bypassing active-session guard for %s",
2755
+ self.name,
2756
+ cmd,
2757
+ session_key,
2758
+ )
2759
+
2760
+ current_guard = self._active_sessions.get(session_key)
2761
+ command_guard = asyncio.Event()
2762
+ self._active_sessions[session_key] = command_guard
2763
+ thread_meta = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
2764
+
2765
+ try:
2766
+ response = await self._message_handler(event)
2767
+ _text, _eph_ttl = self._unwrap_ephemeral(response)
2768
+ # Send the response BEFORE cancelling the old task so the send
2769
+ # cannot be affected by task-cancellation side effects (race
2770
+ # condition fix — issue #18912). Previously the send happened
2771
+ # after cancel_session_processing, which could silently drop the
2772
+ # "/new" confirmation when an agent was actively running.
2773
+ if _text:
2774
+ logger.info(
2775
+ "[%s] Sending command '/%s' response (%d chars) to %s",
2776
+ self.name,
2777
+ cmd,
2778
+ len(_text),
2779
+ event.source.chat_id,
2780
+ )
2781
+ _r = await self._send_with_retry(
2782
+ chat_id=event.source.chat_id,
2783
+ content=_text,
2784
+ reply_to=_reply_anchor_for_event(event),
2785
+ metadata=thread_meta,
2786
+ )
2787
+ if _eph_ttl > 0 and _r.success and _r.message_id:
2788
+ self._schedule_ephemeral_delete(
2789
+ chat_id=event.source.chat_id,
2790
+ message_id=_r.message_id,
2791
+ ttl_seconds=_eph_ttl,
2792
+ )
2793
+ # Old adapter task (if any) is cancelled AFTER the response has
2794
+ # been sent — keeps ordering deterministic and avoids the race.
2795
+ await self.cancel_session_processing(
2796
+ session_key,
2797
+ release_guard=False,
2798
+ discard_pending=False,
2799
+ )
2800
+ except Exception:
2801
+ # On failure, restore the original guard if one still exists so
2802
+ # we don't leave the session in a half-reset state.
2803
+ if self._active_sessions.get(session_key) is command_guard:
2804
+ if session_key in self._session_tasks and current_guard is not None:
2805
+ self._active_sessions[session_key] = current_guard
2806
+ else:
2807
+ self._release_session_guard(session_key, guard=command_guard)
2808
+ raise
2809
+
2810
+ await self._drain_pending_after_session_command(session_key, command_guard)
2811
+
2812
+ async def handle_message(self, event: MessageEvent) -> None:
2813
+ """
2814
+ Process an incoming message.
2815
+
2816
+ This method returns quickly by spawning background tasks.
2817
+ This allows new messages to be processed even while an agent is running,
2818
+ enabling interruption support.
2819
+ """
2820
+ if not self._message_handler:
2821
+ return
2822
+
2823
+ coerce_plaintext_gateway_command(event)
2824
+
2825
+ session_key = build_session_key(
2826
+ event.source,
2827
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
2828
+ thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
2829
+ )
2830
+
2831
+ # On-entry self-heal: if the adapter still has an _active_sessions
2832
+ # entry for this key but the owner task has already exited (done or
2833
+ # cancelled), the lock is stale. Clear it and fall through to
2834
+ # normal dispatch so the user isn't trapped behind a dead guard —
2835
+ # this is the split-brain tail described in issue #11016.
2836
+ if session_key in self._active_sessions:
2837
+ self._heal_stale_session_lock(session_key)
2838
+
2839
+ # Check if there's already an active handler for this session
2840
+ if session_key in self._active_sessions:
2841
+ # Certain commands must bypass the active-session guard and be
2842
+ # dispatched directly to the gateway runner. Without this, they
2843
+ # are queued as pending messages and either:
2844
+ # - leak into the conversation as user text (/stop, /new), or
2845
+ # - deadlock (/approve, /deny — agent is blocked on Event.wait)
2846
+ #
2847
+ # Dispatch inline: call the message handler directly and send the
2848
+ # response. Do NOT use _process_message_background — it manages
2849
+ # session lifecycle and its cleanup races with the running task
2850
+ # (see PR #4926).
2851
+ cmd = event.get_command()
2852
+ from hermes_cli.commands import should_bypass_active_session
2853
+
2854
+ if should_bypass_active_session(cmd):
2855
+ # /stop, /new, /reset must cancel the in-flight adapter task
2856
+ # and preserve ordering of queued follow-ups. Route those
2857
+ # through the dedicated handoff path that serializes
2858
+ # cancellation + runner response + pending drain.
2859
+ if cmd in {"stop", "new", "reset"}:
2860
+ try:
2861
+ await self._dispatch_active_session_command(event, session_key, cmd)
2862
+ except Exception as e:
2863
+ logger.error(
2864
+ "[%s] Command '/%s' dispatch failed: %s",
2865
+ self.name, cmd, e, exc_info=True,
2866
+ )
2867
+ return
2868
+
2869
+ # Other bypass commands (/approve, /deny, /status,
2870
+ # /background, /restart) just need direct dispatch — they
2871
+ # don't cancel the running task.
2872
+ logger.debug(
2873
+ "[%s] Command '/%s' bypassing active-session guard for %s",
2874
+ self.name, cmd, session_key,
2875
+ )
2876
+ try:
2877
+ _thread_meta = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
2878
+ response = await self._message_handler(event)
2879
+ _text, _eph_ttl = self._unwrap_ephemeral(response)
2880
+ if _text:
2881
+ _r = await self._send_with_retry(
2882
+ chat_id=event.source.chat_id,
2883
+ content=_text,
2884
+ reply_to=_reply_anchor_for_event(event),
2885
+ metadata=_thread_meta,
2886
+ )
2887
+ if _eph_ttl > 0 and _r.success and _r.message_id:
2888
+ self._schedule_ephemeral_delete(
2889
+ chat_id=event.source.chat_id,
2890
+ message_id=_r.message_id,
2891
+ ttl_seconds=_eph_ttl,
2892
+ )
2893
+ except Exception as e:
2894
+ logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
2895
+ return
2896
+
2897
+ # Clarify text-capture bypass: if the agent is blocked on a
2898
+ # clarify_tool call awaiting a free-form text response (open-
2899
+ # ended clarify, or user picked "Other"), the next non-command
2900
+ # message in this session MUST reach the runner so the
2901
+ # clarify-intercept can resolve it and unblock the agent.
2902
+ #
2903
+ # Without this bypass: the message gets queued in
2904
+ # _pending_messages AND triggers an interrupt, killing the
2905
+ # agent run mid-clarify and discarding the user's answer.
2906
+ # Same shape as the /approve deadlock fix (PR #4926) — both
2907
+ # cases are "agent thread blocked on Event.wait, message must
2908
+ # reach the resolver before being treated as a new turn."
2909
+ if not cmd:
2910
+ try:
2911
+ from tools import clarify_gateway as _clarify_mod
2912
+ _has_text_clarify = (
2913
+ _clarify_mod.get_pending_for_session(session_key) is not None
2914
+ )
2915
+ except Exception:
2916
+ _has_text_clarify = False
2917
+
2918
+ if _has_text_clarify:
2919
+ logger.debug(
2920
+ "[%s] Routing message to clarify text-intercept for %s",
2921
+ self.name, session_key,
2922
+ )
2923
+ try:
2924
+ _thread_meta = _thread_metadata_for_source(
2925
+ event.source, _reply_anchor_for_event(event)
2926
+ )
2927
+ response = await self._message_handler(event)
2928
+ _text, _eph_ttl = self._unwrap_ephemeral(response)
2929
+ if _text:
2930
+ _r = await self._send_with_retry(
2931
+ chat_id=event.source.chat_id,
2932
+ content=_text,
2933
+ reply_to=_reply_anchor_for_event(event),
2934
+ metadata=_thread_meta,
2935
+ )
2936
+ if _eph_ttl > 0 and _r.success and _r.message_id:
2937
+ self._schedule_ephemeral_delete(
2938
+ chat_id=event.source.chat_id,
2939
+ message_id=_r.message_id,
2940
+ ttl_seconds=_eph_ttl,
2941
+ )
2942
+ except Exception as e:
2943
+ logger.error(
2944
+ "[%s] Clarify text-intercept dispatch failed: %s",
2945
+ self.name, e, exc_info=True,
2946
+ )
2947
+ return
2948
+
2949
+ if self._busy_session_handler is not None:
2950
+ try:
2951
+ if await self._busy_session_handler(event, session_key):
2952
+ return
2953
+ except Exception as e:
2954
+ logger.error("[%s] Busy-session handler failed: %s", self.name, e, exc_info=True)
2955
+
2956
+ # Special case: photo bursts/albums frequently arrive as multiple near-
2957
+ # simultaneous messages. Queue them without interrupting the active run,
2958
+ # then process them immediately after the current task finishes.
2959
+ if event.message_type == MessageType.PHOTO:
2960
+ logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key)
2961
+ merge_pending_message_event(self._pending_messages, session_key, event)
2962
+ return # Don't interrupt now - will run after current task completes
2963
+
2964
+ # Default behavior for non-photo follow-ups: interrupt the running agent.
2965
+ #
2966
+ # Use merge_text=True so rapid TEXT follow-ups (#4469) accumulate
2967
+ # into the single pending slot instead of clobbering each other.
2968
+ # Without merging, three rapid messages "A", "B", "C" land like:
2969
+ # _pending_messages[k] = A (interrupts)
2970
+ # _pending_messages[k] = B (replaces A before consumer reads)
2971
+ # _pending_messages[k] = C (replaces B)
2972
+ # ...and only "C" reaches the next turn. merge_pending_message_event
2973
+ # already does the right thing for photo/media bursts; the
2974
+ # ``merge_text=True`` flag extends that to plain TEXT events.
2975
+ # Same shape as the Telegram bursty-grace path in gateway/run.py.
2976
+ logger.debug("[%s] New message while session %s is active — triggering interrupt", self.name, session_key)
2977
+ merge_pending_message_event(
2978
+ self._pending_messages,
2979
+ session_key,
2980
+ event,
2981
+ merge_text=True,
2982
+ )
2983
+ # Signal the interrupt (the processing task checks this)
2984
+ self._active_sessions[session_key].set()
2985
+ return # Don't process now - will be handled after current task finishes
2986
+
2987
+ # Mark session as active BEFORE spawning background task to close
2988
+ # the race window where a second message arriving before the task
2989
+ # starts would also pass the _active_sessions check and spawn a
2990
+ # duplicate task. (grammY sequentialize / aiogram EventIsolation
2991
+ # pattern — set the guard synchronously, not inside the task.)
2992
+ # _start_session_processing installs the guard AND the owner-task
2993
+ # mapping atomically so stale-lock detection works.
2994
+ self._start_session_processing(event, session_key)
2995
+
2996
+ @staticmethod
2997
+ def _get_human_delay() -> float:
2998
+ """
2999
+ Return a random delay in seconds for human-like response pacing.
3000
+
3001
+ Reads from env vars:
3002
+ HERMES_HUMAN_DELAY_MODE: "off" (default) | "natural" | "custom"
3003
+ HERMES_HUMAN_DELAY_MIN_MS: minimum delay in ms (default 800, custom mode)
3004
+ HERMES_HUMAN_DELAY_MAX_MS: maximum delay in ms (default 2500, custom mode)
3005
+ """
3006
+ mode = os.getenv("HERMES_HUMAN_DELAY_MODE", "off").lower()
3007
+ if mode == "off":
3008
+ return 0.0
3009
+ if mode == "natural":
3010
+ min_ms, max_ms = 800, 2500
3011
+ return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
3012
+ # custom mode — tolerate malformed env vars instead of crashing.
3013
+ try:
3014
+ min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
3015
+ except (TypeError, ValueError):
3016
+ min_ms = 800
3017
+ try:
3018
+ max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
3019
+ except (TypeError, ValueError):
3020
+ max_ms = 2500
3021
+ return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
3022
+
3023
+ async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
3024
+ """Background task that actually processes the message."""
3025
+ # Track delivery outcomes for the processing-complete hook
3026
+ delivery_attempted = False
3027
+ delivery_succeeded = False
3028
+
3029
+ def _record_delivery(result):
3030
+ nonlocal delivery_attempted, delivery_succeeded
3031
+ if result is None:
3032
+ return
3033
+ delivery_attempted = True
3034
+ if getattr(result, "success", False):
3035
+ delivery_succeeded = True
3036
+
3037
+ # Reuse the interrupt event set by handle_message() (which marks
3038
+ # the session active before spawning this task to prevent races).
3039
+ # Fall back to a new Event only if the entry was removed externally.
3040
+ interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
3041
+ self._active_sessions[session_key] = interrupt_event
3042
+
3043
+ # Start continuous typing indicator (refreshes every 2 seconds)
3044
+ _thread_metadata = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
3045
+ _keep_typing_kwargs = {"metadata": _thread_metadata}
3046
+ try:
3047
+ _keep_typing_sig = inspect.signature(self._keep_typing)
3048
+ except (TypeError, ValueError):
3049
+ _keep_typing_sig = None
3050
+ if _keep_typing_sig is None or "stop_event" in _keep_typing_sig.parameters:
3051
+ _keep_typing_kwargs["stop_event"] = interrupt_event
3052
+ typing_task = asyncio.create_task(
3053
+ self._keep_typing(
3054
+ event.source.chat_id,
3055
+ **_keep_typing_kwargs,
3056
+ )
3057
+ )
3058
+
3059
+ async def _stop_typing_task() -> None:
3060
+ typing_task.cancel()
3061
+ try:
3062
+ await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
3063
+ except (asyncio.CancelledError, asyncio.TimeoutError):
3064
+ # Cancellation cleanup must not block adapter shutdown. The
3065
+ # typing task is already cancelled; if the parent task is also
3066
+ # cancelling, let this message-processing task unwind now.
3067
+ pass
3068
+
3069
+ try:
3070
+ await self._run_processing_hook("on_processing_start", event)
3071
+
3072
+ # Call the handler (this can take a while with tool calls)
3073
+ response = await self._message_handler(event)
3074
+
3075
+ # Slash-command handlers may return an EphemeralReply sentinel to
3076
+ # request that their reply message auto-delete after a TTL (used
3077
+ # for system notices like "✨ New session started!" that the user
3078
+ # doesn't need to keep in the thread). Unwrap here so all the
3079
+ # downstream extract_media / text-processing logic sees a plain
3080
+ # string, and remember the TTL + platform capability so the
3081
+ # post-send block can schedule the deletion.
3082
+ response, _ephemeral_ttl = self._unwrap_ephemeral(response)
3083
+
3084
+ # Send response if any. A None/empty response is normal when
3085
+ # streaming already delivered the text (already_sent=True) or
3086
+ # when the message was queued behind an active agent. Log at
3087
+ # DEBUG to avoid noisy warnings for expected behavior.
3088
+ #
3089
+ # Suppress stale response when the session was interrupted by a
3090
+ # new message that hasn't been consumed yet. The pending message
3091
+ # is processed by the pending-message handler below (#8221/#2483).
3092
+ if (
3093
+ response
3094
+ and interrupt_event.is_set()
3095
+ and session_key in self._pending_messages
3096
+ ):
3097
+ logger.info(
3098
+ "[%s] Suppressing stale response for interrupted session %s",
3099
+ self.name,
3100
+ session_key,
3101
+ )
3102
+ response = None
3103
+ if not response:
3104
+ logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
3105
+ if response:
3106
+ # Capture [[as_document]] before extract_media strips it, so the
3107
+ # dispatch partition below can route image-extension files
3108
+ # through send_document instead of send_multiple_images. Used
3109
+ # by skills that produce large/lossless images (e.g. info-graph)
3110
+ # where Telegram's sendPhoto recompression destroys legibility.
3111
+ force_document_attachments = "[[as_document]]" in response
3112
+
3113
+ # Extract MEDIA:<path> tags (from TTS tool) before other processing
3114
+ media_files, response = self.extract_media(response)
3115
+
3116
+ # Extract image URLs and send them as native platform attachments
3117
+ images, text_content = self.extract_images(response)
3118
+ # Strip any remaining internal directives from message body (fixes #1561)
3119
+ text_content = text_content.replace("[[audio_as_voice]]", "").strip()
3120
+ text_content = text_content.replace("[[as_document]]", "").strip()
3121
+ text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
3122
+ if images:
3123
+ logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
3124
+
3125
+ # Auto-detect bare local file paths for native media delivery
3126
+ # (helps small models that don't use MEDIA: syntax)
3127
+ local_files, text_content = self.extract_local_files(text_content)
3128
+ if local_files:
3129
+ logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
3130
+
3131
+ # Auto-TTS: if voice message, generate audio FIRST (before sending text)
3132
+ # Gated via ``_should_auto_tts_for_chat``: fires when the chat has
3133
+ # an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
3134
+ # True globally and no ``/voice off`` has been issued.
3135
+ _tts_path = None
3136
+ if (self._should_auto_tts_for_chat(event.source.chat_id)
3137
+ and event.message_type == MessageType.VOICE
3138
+ and text_content
3139
+ and not media_files):
3140
+ try:
3141
+ from tools.tts_tool import text_to_speech_tool, check_tts_requirements
3142
+ if check_tts_requirements():
3143
+ import json as _json
3144
+ speech_text = re.sub(r'[*_`#\[\]()]', '', text_content)[:4000].strip()
3145
+ if not speech_text:
3146
+ raise ValueError("Empty text after markdown cleanup")
3147
+ tts_result_str = await asyncio.to_thread(
3148
+ text_to_speech_tool, text=speech_text
3149
+ )
3150
+ tts_data = _json.loads(tts_result_str)
3151
+ _tts_path = tts_data.get("file_path")
3152
+ except Exception as tts_err:
3153
+ logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err)
3154
+
3155
+ # Play TTS audio before text (voice-first experience)
3156
+ if _tts_path and Path(_tts_path).exists():
3157
+ try:
3158
+ await self.play_tts(
3159
+ chat_id=event.source.chat_id,
3160
+ audio_path=_tts_path,
3161
+ metadata=_thread_metadata,
3162
+ )
3163
+ finally:
3164
+ try:
3165
+ os.remove(_tts_path)
3166
+ except OSError:
3167
+ pass
3168
+
3169
+ # Send the text portion
3170
+ if text_content:
3171
+ logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
3172
+ _reply_anchor = _reply_anchor_for_event(event)
3173
+ # Mark final response messages for notification delivery.
3174
+ # Platform adapters that support per-message notification
3175
+ # control (e.g. Telegram's disable_notification) use this
3176
+ # flag to override silent-mode and ensure the final
3177
+ # response triggers a push notification.
3178
+ # Clone to avoid mutating the metadata shared with the
3179
+ # typing-indicator task (which must remain unmarked).
3180
+ if _thread_metadata is not None:
3181
+ _thread_metadata = dict(_thread_metadata)
3182
+ _thread_metadata["notify"] = True
3183
+ else:
3184
+ _thread_metadata = {"notify": True}
3185
+ result = await self._send_with_retry(
3186
+ chat_id=event.source.chat_id,
3187
+ content=text_content,
3188
+ reply_to=_reply_anchor,
3189
+ metadata=_thread_metadata,
3190
+ )
3191
+ _record_delivery(result)
3192
+
3193
+ # Schedule auto-deletion of system-notice replies.
3194
+ # Detached so the handler returns immediately; errors
3195
+ # (permission denied, message too old) are swallowed.
3196
+ if (
3197
+ _ephemeral_ttl
3198
+ and _ephemeral_ttl > 0
3199
+ and result.success
3200
+ and result.message_id
3201
+ ):
3202
+ self._schedule_ephemeral_delete(
3203
+ chat_id=event.source.chat_id,
3204
+ message_id=result.message_id,
3205
+ ttl_seconds=_ephemeral_ttl,
3206
+ )
3207
+
3208
+ # Human-like pacing delay between text and media
3209
+ human_delay = self._get_human_delay()
3210
+
3211
+ # Send extracted images as native attachments
3212
+ if images:
3213
+ logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images))
3214
+ try:
3215
+ await self.send_multiple_images(
3216
+ chat_id=event.source.chat_id,
3217
+ images=images,
3218
+ metadata=_thread_metadata,
3219
+ human_delay=human_delay,
3220
+ )
3221
+ except Exception as batch_err:
3222
+ logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
3223
+
3224
+
3225
+ # Send extracted media files — route by file type
3226
+ _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
3227
+ _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
3228
+
3229
+ # Partition images out of media_files + local_files so they
3230
+ # can be sent as a single batch (Signal RPC). When
3231
+ # ``[[as_document]]`` was set on the original response, image
3232
+ # files skip the photo path and route to send_document below
3233
+ # so they're delivered with original bytes (no Telegram
3234
+ # sendPhoto recompression).
3235
+ from urllib.parse import quote as _quote
3236
+ _image_paths: list = []
3237
+ _non_image_media: list = []
3238
+ for media_path, is_voice in media_files:
3239
+ _ext = Path(media_path).suffix.lower()
3240
+ if (_ext in _IMAGE_EXTS
3241
+ and not is_voice
3242
+ and not force_document_attachments):
3243
+ _image_paths.append(media_path)
3244
+ else:
3245
+ _non_image_media.append((media_path, is_voice))
3246
+ _non_image_local: list = []
3247
+ for file_path in local_files:
3248
+ if (Path(file_path).suffix.lower() in _IMAGE_EXTS
3249
+ and not force_document_attachments):
3250
+ _image_paths.append(file_path)
3251
+ else:
3252
+ _non_image_local.append(file_path)
3253
+
3254
+ if _image_paths:
3255
+ try:
3256
+ _batch = [(f"file://{_quote(p)}", "") for p in _image_paths]
3257
+ await self.send_multiple_images(
3258
+ chat_id=event.source.chat_id,
3259
+ images=_batch,
3260
+ metadata=_thread_metadata,
3261
+ human_delay=human_delay,
3262
+ )
3263
+ except Exception as batch_err:
3264
+ logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
3265
+
3266
+ for media_path, is_voice in _non_image_media:
3267
+ if human_delay > 0:
3268
+ await asyncio.sleep(human_delay)
3269
+ try:
3270
+ ext = Path(media_path).suffix.lower()
3271
+ if should_send_media_as_audio(self.platform, ext, is_voice=is_voice):
3272
+ media_result = await self.send_voice(
3273
+ chat_id=event.source.chat_id,
3274
+ audio_path=media_path,
3275
+ metadata=_thread_metadata,
3276
+ )
3277
+ elif ext in _VIDEO_EXTS:
3278
+ media_result = await self.send_video(
3279
+ chat_id=event.source.chat_id,
3280
+ video_path=media_path,
3281
+ metadata=_thread_metadata,
3282
+ )
3283
+ else:
3284
+ media_result = await self.send_document(
3285
+ chat_id=event.source.chat_id,
3286
+ file_path=media_path,
3287
+ metadata=_thread_metadata,
3288
+ )
3289
+
3290
+ if not media_result.success:
3291
+ logger.warning("[%s] Failed to send media (%s): %s", self.name, ext, media_result.error)
3292
+ except Exception as media_err:
3293
+ logger.warning("[%s] Error sending media: %s", self.name, media_err)
3294
+
3295
+ # Send auto-detected local non-image files as native attachments
3296
+ for file_path in _non_image_local:
3297
+ if human_delay > 0:
3298
+ await asyncio.sleep(human_delay)
3299
+ try:
3300
+ ext = Path(file_path).suffix.lower()
3301
+ if ext in _VIDEO_EXTS:
3302
+ await self.send_video(
3303
+ chat_id=event.source.chat_id,
3304
+ video_path=file_path,
3305
+ metadata=_thread_metadata,
3306
+ )
3307
+ else:
3308
+ await self.send_document(
3309
+ chat_id=event.source.chat_id,
3310
+ file_path=file_path,
3311
+ metadata=_thread_metadata,
3312
+ )
3313
+ except Exception as file_err:
3314
+ logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
3315
+
3316
+ # Determine overall success for the processing hook
3317
+ processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
3318
+ await self._run_processing_hook(
3319
+ "on_processing_complete",
3320
+ event,
3321
+ ProcessingOutcome.SUCCESS if processing_ok else ProcessingOutcome.FAILURE,
3322
+ )
3323
+
3324
+ # Check if there's a pending message that was queued during our processing
3325
+ if session_key in self._pending_messages:
3326
+ pending_event = self._pending_messages.pop(session_key)
3327
+ logger.debug("[%s] Processing queued message from interrupt", self.name)
3328
+ # Keep the _active_sessions entry live across the turn chain
3329
+ # and only CLEAR the interrupt Event — do NOT delete the entry.
3330
+ # If we deleted here, a concurrent inbound message arriving
3331
+ # during the awaits below would pass the Level-1 guard, spawn
3332
+ # its own _process_message_background, and run simultaneously
3333
+ # with the recursive drain below. Two agents on one
3334
+ # session_key = duplicate responses, duplicate tool calls.
3335
+ # Clearing the Event keeps the guard live so follow-ups take
3336
+ # the busy-handler path (queue + interrupt) as intended.
3337
+ _active = self._active_sessions.get(session_key)
3338
+ if _active is not None:
3339
+ _active.clear()
3340
+ await _stop_typing_task()
3341
+ # Spawn a fresh task for the pending message instead of
3342
+ # recursing. Issue #17758: `await
3343
+ # self._process_message_background(...)` here grew the
3344
+ # call stack one frame per chained follow-up, and under
3345
+ # sustained pending-queue activity the C stack would
3346
+ # exhaust at ~2000 frames and SIGSEGV the process.
3347
+ # Mirror the late-arrival drain pattern below: hand off
3348
+ # to a new task and return so this frame can unwind.
3349
+ drain_task = asyncio.create_task(
3350
+ self._process_message_background(pending_event, session_key)
3351
+ )
3352
+ # Hand ownership of the session to the drain task so
3353
+ # stale-lock detection keeps working while it runs.
3354
+ self._session_tasks[session_key] = drain_task
3355
+ try:
3356
+ self._background_tasks.add(drain_task)
3357
+ drain_task.add_done_callback(self._background_tasks.discard)
3358
+ except TypeError:
3359
+ # Tests stub create_task() with non-hashable sentinels; tolerate.
3360
+ pass
3361
+ return # Drain task owns the session now.
3362
+
3363
+ except asyncio.CancelledError:
3364
+ current_task = asyncio.current_task()
3365
+ outcome = ProcessingOutcome.CANCELLED
3366
+ if current_task is None or current_task not in self._expected_cancelled_tasks:
3367
+ outcome = ProcessingOutcome.FAILURE
3368
+ await self._run_processing_hook("on_processing_complete", event, outcome)
3369
+ raise
3370
+ except Exception as e:
3371
+ await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE)
3372
+ logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
3373
+ # Send the error to the user so they aren't left with radio silence
3374
+ try:
3375
+ error_type = type(e).__name__
3376
+ error_detail = str(e)[:300] if str(e) else "no details available"
3377
+ _thread_metadata = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
3378
+ await self.send(
3379
+ chat_id=event.source.chat_id,
3380
+ content=(
3381
+ f"Sorry, I encountered an error ({error_type}).\n"
3382
+ f"{error_detail}\n"
3383
+ "Try again or use /reset to start a fresh session."
3384
+ ),
3385
+ metadata=_thread_metadata,
3386
+ )
3387
+ except Exception:
3388
+ pass # Last resort — don't let error reporting crash the handler
3389
+ finally:
3390
+ # Fire any one-shot post-delivery callback registered for this
3391
+ # session (e.g. deferred background-review notifications).
3392
+ #
3393
+ # Snapshot the callback generation HERE (after the agent has run),
3394
+ # not at the top of this task. _hermes_run_generation is set on
3395
+ # the interrupt event by GatewayRunner._bind_adapter_run_generation
3396
+ # during _handle_message_with_agent — which happens DURING the
3397
+ # self._message_handler(event) await above. Snapshotting earlier
3398
+ # always captured None, which bypassed the generation-ownership
3399
+ # check in pop_post_delivery_callback and let stale runs fire a
3400
+ # fresher run's callbacks.
3401
+ _callback_generation = getattr(
3402
+ interrupt_event,
3403
+ "_hermes_run_generation",
3404
+ None,
3405
+ )
3406
+ if hasattr(self, "pop_post_delivery_callback"):
3407
+ _post_cb = self.pop_post_delivery_callback(
3408
+ session_key,
3409
+ generation=_callback_generation,
3410
+ )
3411
+ else:
3412
+ _post_cb = getattr(self, "_post_delivery_callbacks", {}).pop(session_key, None)
3413
+ if callable(_post_cb):
3414
+ try:
3415
+ _post_result = _post_cb()
3416
+ if inspect.isawaitable(_post_result):
3417
+ await _post_result
3418
+ except Exception:
3419
+ pass
3420
+ # Stop typing indicator
3421
+ await _stop_typing_task()
3422
+ # Also cancel any platform-level persistent typing tasks (e.g. Discord)
3423
+ # that may have been recreated by _keep_typing after the last stop_typing()
3424
+ try:
3425
+ if hasattr(self, "stop_typing"):
3426
+ await self.stop_typing(event.source.chat_id)
3427
+ except Exception:
3428
+ pass
3429
+ # Late-arrival drain: a message may have arrived during the
3430
+ # cleanup awaits above (typing_task cancel, stop_typing). Such
3431
+ # messages passed the Level-1 guard (entry still live, Event
3432
+ # possibly set) and landed in _pending_messages via the
3433
+ # busy-handler path. Without this block, we would delete the
3434
+ # active-session entry and the queued message would be silently
3435
+ # dropped (user never gets a reply).
3436
+ late_pending = self._pending_messages.pop(session_key, None)
3437
+ if late_pending is not None:
3438
+ current_task = asyncio.current_task()
3439
+ existing_task = self._session_tasks.get(session_key)
3440
+ if (
3441
+ existing_task is not None
3442
+ and existing_task is not current_task
3443
+ ):
3444
+ # The in-band drain (or an earlier late-arrival drain)
3445
+ # already spawned a follow-up task that owns this
3446
+ # session. Re-queue the late-arrival event so that
3447
+ # task picks it up — avoids spawning two concurrent
3448
+ # _process_message_background tasks for the same key
3449
+ # (#17758 follow-up: prevents the create_task path
3450
+ # from racing with itself across the in-band/finally
3451
+ # boundary).
3452
+ self._pending_messages[session_key] = late_pending
3453
+ else:
3454
+ logger.debug(
3455
+ "[%s] Late-arrival pending message during cleanup — spawning drain task",
3456
+ self.name,
3457
+ )
3458
+ _active = self._active_sessions.get(session_key)
3459
+ if _active is not None:
3460
+ _active.clear()
3461
+ drain_task = asyncio.create_task(
3462
+ self._process_message_background(late_pending, session_key)
3463
+ )
3464
+ # Hand ownership of the session to the drain task so stale-lock
3465
+ # detection keeps working while it runs.
3466
+ self._session_tasks[session_key] = drain_task
3467
+ try:
3468
+ self._background_tasks.add(drain_task)
3469
+ drain_task.add_done_callback(self._background_tasks.discard)
3470
+ except TypeError:
3471
+ # Tests stub create_task() with non-hashable sentinels; tolerate.
3472
+ pass
3473
+ # Leave _active_sessions[session_key] populated — the drain
3474
+ # task's own lifecycle will clean it up.
3475
+ else:
3476
+ # Clean up session tracking. Guard-match both deletes so a
3477
+ # reset-like command that already swapped in its own
3478
+ # command_guard (and cancelled us) can't be accidentally
3479
+ # cleared by our unwind. The command owns the session now.
3480
+ #
3481
+ # The owner-check also covers the in-band drain handoff
3482
+ # above: when we spawned a drain_task and transferred
3483
+ # ownership via ``_session_tasks[session_key] = drain_task``,
3484
+ # ``_session_tasks.get(session_key) is current_task`` is
3485
+ # False, so we leave _active_sessions populated. Without
3486
+ # this guard, the drain task picks up the same
3487
+ # interrupt_event in its own _process_message_background
3488
+ # entry, _release_session_guard's guard-match succeeds,
3489
+ # and we'd delete the entry while the drain task is still
3490
+ # running — letting a concurrent inbound message pass
3491
+ # the Level-1 guard and spawn a second handler for the
3492
+ # same session.
3493
+ current_task = asyncio.current_task()
3494
+ if current_task is not None and self._session_tasks.get(session_key) is current_task:
3495
+ del self._session_tasks[session_key]
3496
+ self._release_session_guard(session_key, guard=interrupt_event)
3497
+
3498
+ async def cancel_background_tasks(self) -> None:
3499
+ """Cancel any in-flight background message-processing tasks.
3500
+
3501
+ Used during gateway shutdown/replacement so active sessions from the old
3502
+ process do not keep running after adapters are being torn down.
3503
+
3504
+ Each cancelled task is awaited with a 5s bound so a wedged finally
3505
+ (typing-task cleanup, on_processing_complete hook) can't stall the
3506
+ whole shutdown path. Stragglers are released from our tracking and
3507
+ allowed to finish unwinding on their own.
3508
+ """
3509
+ # Loop until no new tasks appear. Without this, a message
3510
+ # arriving during the `await asyncio.gather` below would spawn
3511
+ # a fresh _process_message_background task (added to
3512
+ # self._background_tasks at line ~1668 via handle_message),
3513
+ # and the _background_tasks.clear() at the end of this method
3514
+ # would drop the reference — the task runs untracked against a
3515
+ # disconnecting adapter, logs send-failures, and may linger
3516
+ # until it completes on its own. Retrying the drain until the
3517
+ # task set stabilizes closes the window.
3518
+ MAX_DRAIN_ROUNDS = 5
3519
+ for _ in range(MAX_DRAIN_ROUNDS):
3520
+ tasks = [task for task in self._background_tasks if not task.done()]
3521
+ if not tasks:
3522
+ break
3523
+ for task in tasks:
3524
+ self._expected_cancelled_tasks.add(task)
3525
+ task.cancel()
3526
+ try:
3527
+ await asyncio.wait_for(
3528
+ asyncio.gather(
3529
+ *(asyncio.shield(t) for t in tasks),
3530
+ return_exceptions=True,
3531
+ ),
3532
+ timeout=5.0,
3533
+ )
3534
+ except asyncio.TimeoutError:
3535
+ logger.warning(
3536
+ "[%s] %d background task(s) did not exit within 5s; "
3537
+ "releasing tracking and letting them unwind in the background",
3538
+ self.name, len([t for t in tasks if not t.done()]),
3539
+ )
3540
+ break
3541
+ # Loop: late-arrival tasks spawned during the gather above
3542
+ # will be in self._background_tasks now. Re-check.
3543
+ self._background_tasks.clear()
3544
+ self._expected_cancelled_tasks.clear()
3545
+ self._session_tasks.clear()
3546
+ self._pending_messages.clear()
3547
+ self._active_sessions.clear()
3548
+
3549
+ def has_pending_interrupt(self, session_key: str) -> bool:
3550
+ """Check if there's a pending interrupt for a session."""
3551
+ return session_key in self._active_sessions and self._active_sessions[session_key].is_set()
3552
+
3553
+ def get_pending_message(self, session_key: str) -> Optional[MessageEvent]:
3554
+ """Get and clear any pending message for a session."""
3555
+ return self._pending_messages.pop(session_key, None)
3556
+
3557
+ def build_source(
3558
+ self,
3559
+ chat_id: str,
3560
+ chat_name: Optional[str] = None,
3561
+ chat_type: str = "dm",
3562
+ user_id: Optional[str] = None,
3563
+ user_name: Optional[str] = None,
3564
+ thread_id: Optional[str] = None,
3565
+ chat_topic: Optional[str] = None,
3566
+ user_id_alt: Optional[str] = None,
3567
+ chat_id_alt: Optional[str] = None,
3568
+ is_bot: bool = False,
3569
+ guild_id: Optional[str] = None,
3570
+ parent_chat_id: Optional[str] = None,
3571
+ message_id: Optional[str] = None,
3572
+ ) -> SessionSource:
3573
+ """Helper to build a SessionSource for this platform."""
3574
+ # Normalize empty topic to None
3575
+ if chat_topic is not None and not chat_topic.strip():
3576
+ chat_topic = None
3577
+ return SessionSource(
3578
+ platform=self.platform,
3579
+ chat_id=str(chat_id),
3580
+ chat_name=chat_name,
3581
+ chat_type=chat_type,
3582
+ user_id=str(user_id) if user_id else None,
3583
+ user_name=user_name,
3584
+ thread_id=str(thread_id) if thread_id else None,
3585
+ chat_topic=chat_topic.strip() if chat_topic else None,
3586
+ user_id_alt=user_id_alt,
3587
+ chat_id_alt=chat_id_alt,
3588
+ is_bot=is_bot,
3589
+ guild_id=str(guild_id) if guild_id else None,
3590
+ parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
3591
+ message_id=str(message_id) if message_id else None,
3592
+ )
3593
+
3594
+ @abstractmethod
3595
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
3596
+ """
3597
+ Get information about a chat/channel.
3598
+
3599
+ Returns dict with at least:
3600
+ - name: Chat name
3601
+ - type: "dm", "group", "channel"
3602
+ """
3603
+ pass
3604
+
3605
+ def format_message(self, content: str) -> str:
3606
+ """
3607
+ Format a message for this platform.
3608
+
3609
+ Override in subclasses to handle platform-specific formatting
3610
+ (e.g., Telegram MarkdownV2, Discord markdown).
3611
+
3612
+ Default implementation returns content as-is.
3613
+ """
3614
+ return content
3615
+
3616
+ @staticmethod
3617
+ def truncate_message(
3618
+ content: str,
3619
+ max_length: int = 4096,
3620
+ len_fn: Optional["Callable[[str], int]"] = None,
3621
+ ) -> List[str]:
3622
+ """
3623
+ Split a long message into chunks, preserving code block boundaries.
3624
+
3625
+ When a split falls inside a triple-backtick code block, the fence is
3626
+ closed at the end of the current chunk and reopened (with the original
3627
+ language tag) at the start of the next chunk. Multi-chunk responses
3628
+ receive indicators like ``(1/3)``.
3629
+
3630
+ Args:
3631
+ content: The full message content
3632
+ max_length: Maximum length per chunk (platform-specific)
3633
+ len_fn: Optional length function for measuring string length.
3634
+ Defaults to ``len`` (Unicode code-points). Pass
3635
+ ``utf16_len`` for platforms that measure message
3636
+ length in UTF-16 code units (e.g. Telegram).
3637
+
3638
+ Returns:
3639
+ List of message chunks
3640
+ """
3641
+ _len = len_fn or len
3642
+ if _len(content) <= max_length:
3643
+ return [content]
3644
+
3645
+ INDICATOR_RESERVE = 10 # room for " (XX/XX)"
3646
+ FENCE_CLOSE = "\n```"
3647
+
3648
+ chunks: List[str] = []
3649
+ remaining = content
3650
+ # When the previous chunk ended mid-code-block, this holds the
3651
+ # language tag (possibly "") so we can reopen the fence.
3652
+ carry_lang: Optional[str] = None
3653
+
3654
+ while remaining:
3655
+ # If we're continuing a code block from the previous chunk,
3656
+ # prepend a new opening fence with the same language tag.
3657
+ prefix = f"```{carry_lang}\n" if carry_lang is not None else ""
3658
+
3659
+ # How much body text we can fit after accounting for the prefix,
3660
+ # a potential closing fence, and the chunk indicator.
3661
+ headroom = max_length - INDICATOR_RESERVE - _len(prefix) - _len(FENCE_CLOSE)
3662
+ if headroom < 1:
3663
+ headroom = max_length // 2
3664
+
3665
+ # Everything remaining fits in one final chunk
3666
+ if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE:
3667
+ chunks.append(prefix + remaining)
3668
+ break
3669
+
3670
+ # Find a natural split point (prefer newlines, then spaces).
3671
+ # When _len != len (e.g. utf16_len for Telegram), headroom is
3672
+ # measured in the custom unit. We need codepoint-based slice
3673
+ # positions that stay within the custom-unit budget.
3674
+ #
3675
+ # _safe_slice_pos() maps a custom-unit budget to the largest
3676
+ # codepoint offset whose custom length ≤ budget.
3677
+ if _len is not len:
3678
+ # Map headroom (custom units) → codepoint slice length
3679
+ _cp_limit = _custom_unit_to_cp(remaining, headroom, _len)
3680
+ else:
3681
+ _cp_limit = headroom
3682
+ region = remaining[:_cp_limit]
3683
+ split_at = region.rfind("\n")
3684
+ if split_at < _cp_limit // 2:
3685
+ split_at = region.rfind(" ")
3686
+ if split_at < 1:
3687
+ split_at = _cp_limit
3688
+
3689
+ # Avoid splitting inside an inline code span (`...`).
3690
+ # If the text before split_at has an odd number of unescaped
3691
+ # backticks, the split falls inside inline code — the resulting
3692
+ # chunk would have an unpaired backtick and any special characters
3693
+ # (like parentheses) inside the broken span would be unescaped,
3694
+ # causing MarkdownV2 parse errors on Telegram.
3695
+ candidate = remaining[:split_at]
3696
+ backtick_count = candidate.count("`") - candidate.count("\\`")
3697
+ if backtick_count % 2 == 1:
3698
+ # Find the last unescaped backtick and split before it
3699
+ last_bt = candidate.rfind("`")
3700
+ while last_bt > 0 and candidate[last_bt - 1] == "\\":
3701
+ last_bt = candidate.rfind("`", 0, last_bt)
3702
+ if last_bt > 0:
3703
+ # Try to find a space or newline just before the backtick
3704
+ safe_split = candidate.rfind(" ", 0, last_bt)
3705
+ nl_split = candidate.rfind("\n", 0, last_bt)
3706
+ safe_split = max(safe_split, nl_split)
3707
+ if safe_split > _cp_limit // 4:
3708
+ split_at = safe_split
3709
+
3710
+ chunk_body = remaining[:split_at]
3711
+ remaining = remaining[split_at:].lstrip()
3712
+
3713
+ full_chunk = prefix + chunk_body
3714
+
3715
+ # Walk only the chunk_body (not the prefix we prepended) to
3716
+ # determine whether we end inside an open code block.
3717
+ in_code = carry_lang is not None
3718
+ lang = carry_lang or ""
3719
+ for line in chunk_body.split("\n"):
3720
+ stripped = line.strip()
3721
+ if stripped.startswith("```"):
3722
+ if in_code:
3723
+ in_code = False
3724
+ lang = ""
3725
+ else:
3726
+ in_code = True
3727
+ tag = stripped[3:].strip()
3728
+ lang = tag.split()[0] if tag else ""
3729
+
3730
+ if in_code:
3731
+ # Close the orphaned fence so the chunk is valid on its own
3732
+ full_chunk += FENCE_CLOSE
3733
+ carry_lang = lang
3734
+ else:
3735
+ carry_lang = None
3736
+
3737
+ chunks.append(full_chunk)
3738
+
3739
+ # Append chunk indicators when the response spans multiple messages
3740
+ if len(chunks) > 1:
3741
+ total = len(chunks)
3742
+ chunks = [
3743
+ f"{chunk} ({i + 1}/{total})" for i, chunk in enumerate(chunks)
3744
+ ]
3745
+
3746
+ return chunks
3747
+