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,3072 @@
1
+ """
2
+ QQ Bot platform adapter using the Official QQ Bot API (v2).
3
+
4
+ Connects to the QQ Bot WebSocket Gateway for inbound events and uses the
5
+ REST API (``api.sgroup.qq.com``) for outbound messages and media uploads.
6
+
7
+ Configuration in config.yaml:
8
+ platforms:
9
+ qq:
10
+ enabled: true
11
+ extra:
12
+ app_id: "your-app-id" # or QQ_APP_ID env var
13
+ client_secret: "your-secret" # or QQ_CLIENT_SECRET env var
14
+ markdown_support: true # enable QQ markdown (msg_type 2)
15
+ dm_policy: "open" # open | allowlist | disabled
16
+ allow_from: ["openid_1"]
17
+ group_policy: "open" # open | allowlist | disabled
18
+ group_allow_from: ["group_openid_1"]
19
+ stt: # Voice-to-text config (optional)
20
+ provider: "zai" # zai (GLM-ASR), openai (Whisper), etc.
21
+ baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4"
22
+ apiKey: "your-stt-api-key" # or set QQ_STT_API_KEY env var
23
+ model: "glm-asr" # glm-asr, whisper-1, etc.
24
+
25
+ Voice transcription priority:
26
+ 1. QQ's built-in ``asr_refer_text`` (Tencent ASR — free, always tried first)
27
+ 2. Configured STT provider via ``stt`` config or ``QQ_STT_*`` env vars
28
+
29
+ Reference: https://bot.q.qq.com/wiki/develop/api-v2/
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import base64
36
+ import json
37
+ import logging
38
+ import mimetypes
39
+ import os
40
+ import time
41
+ import uuid
42
+ from datetime import datetime, timezone
43
+ from pathlib import Path
44
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
45
+ from urllib.parse import urlparse
46
+
47
+ try:
48
+ import aiohttp
49
+
50
+ AIOHTTP_AVAILABLE = True
51
+ except ImportError:
52
+ AIOHTTP_AVAILABLE = False
53
+ aiohttp = None # type: ignore[assignment]
54
+
55
+ try:
56
+ import httpx
57
+
58
+ HTTPX_AVAILABLE = True
59
+ except ImportError:
60
+ HTTPX_AVAILABLE = False
61
+ httpx = None # type: ignore[assignment]
62
+
63
+ from gateway.config import Platform, PlatformConfig
64
+ from gateway.platforms.base import (
65
+ BasePlatformAdapter,
66
+ MessageEvent,
67
+ MessageType,
68
+ SendResult,
69
+ _ssrf_redirect_guard,
70
+ cache_document_from_bytes,
71
+ cache_image_from_bytes,
72
+ )
73
+ from gateway.platforms.helpers import strip_markdown
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+
78
+ class QQCloseError(Exception):
79
+ """Raised when QQ WebSocket closes with a specific code.
80
+
81
+ Carries the close code and reason for proper handling in the reconnect loop.
82
+ """
83
+
84
+ def __init__(self, code, reason=""):
85
+ self.code = int(code) if code else None
86
+ self.reason = str(reason) if reason else ""
87
+ super().__init__(f"WebSocket closed (code={self.code}, reason={self.reason})")
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Constants — imported from the shared constants module.
92
+ # ---------------------------------------------------------------------------
93
+
94
+ from gateway.platforms.qqbot.constants import (
95
+ API_BASE,
96
+ TOKEN_URL,
97
+ GATEWAY_URL_PATH,
98
+ DEFAULT_API_TIMEOUT,
99
+ FILE_UPLOAD_TIMEOUT,
100
+ CONNECT_TIMEOUT_SECONDS,
101
+ RECONNECT_BACKOFF,
102
+ MAX_RECONNECT_ATTEMPTS,
103
+ RATE_LIMIT_DELAY,
104
+ QUICK_DISCONNECT_THRESHOLD,
105
+ MAX_QUICK_DISCONNECT_COUNT,
106
+ MAX_MESSAGE_LENGTH,
107
+ DEDUP_WINDOW_SECONDS,
108
+ DEDUP_MAX_SIZE,
109
+ MSG_TYPE_TEXT,
110
+ MSG_TYPE_MARKDOWN,
111
+ MSG_TYPE_MEDIA,
112
+ MSG_TYPE_INPUT_NOTIFY,
113
+ MEDIA_TYPE_IMAGE,
114
+ MEDIA_TYPE_VIDEO,
115
+ MEDIA_TYPE_VOICE,
116
+ MEDIA_TYPE_FILE,
117
+ )
118
+ from gateway.platforms.qqbot.utils import (
119
+ coerce_list as _coerce_list_impl,
120
+ build_user_agent,
121
+ )
122
+ from gateway.platforms.qqbot.chunked_upload import (
123
+ ChunkedUploader,
124
+ UploadDailyLimitExceededError,
125
+ UploadFileTooLargeError,
126
+ )
127
+ from gateway.platforms.qqbot.keyboards import (
128
+ ApprovalRequest,
129
+ ApprovalSender,
130
+ InlineKeyboard,
131
+ InteractionEvent,
132
+ build_approval_keyboard,
133
+ build_update_prompt_keyboard,
134
+ parse_approval_button_data,
135
+ parse_interaction_event,
136
+ parse_update_prompt_button_data,
137
+ )
138
+
139
+
140
+ def check_qq_requirements() -> bool:
141
+ """Check if QQ runtime dependencies are available."""
142
+ return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
143
+
144
+
145
+ def _coerce_list(value: Any) -> List[str]:
146
+ """Coerce config values into a trimmed string list."""
147
+ return _coerce_list_impl(value)
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # QQAdapter
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class QQAdapter(BasePlatformAdapter):
156
+ """QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API."""
157
+
158
+ # QQ Bot API does not support editing sent messages.
159
+ SUPPORTS_MESSAGE_EDITING = False
160
+ MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
161
+ _TYPING_INPUT_SECONDS = 60 # input_notify duration reported to QQ
162
+ _TYPING_DEBOUNCE_SECONDS = 50 # refresh before it expires
163
+
164
+ @property
165
+ def _log_tag(self) -> str:
166
+ """Log prefix including app_id for multi-instance disambiguation."""
167
+ app_id = getattr(self, "_app_id", None)
168
+ if app_id:
169
+ return f"QQBot:{app_id}"
170
+ return "QQBot"
171
+
172
+ def _fail_pending(self, reason: str) -> None:
173
+ """Fail all pending response futures."""
174
+ for fut in self._pending_responses.values():
175
+ if not fut.done():
176
+ fut.set_exception(RuntimeError(reason))
177
+ self._pending_responses.clear()
178
+
179
+ def _mark_transport_disconnected(self) -> None:
180
+ """Mark QQ WS down without stopping the reconnect loop.
181
+
182
+ BasePlatformAdapter uses _running for both process lifecycle and
183
+ connection status. QQBot needs to keep the listener task alive across
184
+ transient transport drops so it can continue reconnect attempts after a
185
+ short-lived gateway or network failure.
186
+ """
187
+ if self.has_fatal_error:
188
+ return
189
+ self._write_runtime_status_safe(
190
+ "disconnected",
191
+ platform_state="disconnected",
192
+ error_code=None,
193
+ error_message=None,
194
+ )
195
+
196
+ @property
197
+ def is_connected(self) -> bool:
198
+ """Return True only when the QQ WebSocket transport is usable."""
199
+ return bool(self._running and self._ws and not self._ws.closed)
200
+
201
+ def __init__(self, config: PlatformConfig):
202
+ super().__init__(config, Platform.QQBOT)
203
+
204
+ extra = config.extra or {}
205
+ self._app_id = str(extra.get("app_id") or os.getenv("QQ_APP_ID", "")).strip()
206
+ self._client_secret = str(
207
+ extra.get("client_secret") or os.getenv("QQ_CLIENT_SECRET", "")
208
+ ).strip()
209
+ self._markdown_support = bool(extra.get("markdown_support", True))
210
+
211
+ # Auth/ACL policies
212
+ self._dm_policy = str(extra.get("dm_policy", "open")).strip().lower()
213
+ self._allow_from = _coerce_list(
214
+ extra.get("allow_from") or extra.get("allowFrom")
215
+ )
216
+ self._group_policy = str(extra.get("group_policy", "open")).strip().lower()
217
+ self._group_allow_from = _coerce_list(
218
+ extra.get("group_allow_from") or extra.get("groupAllowFrom")
219
+ )
220
+
221
+ # Connection state
222
+ self._session: Optional[aiohttp.ClientSession] = None
223
+ self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
224
+ self._http_client: Optional[httpx.AsyncClient] = None
225
+ self._listen_task: Optional[asyncio.Task] = None
226
+ self._heartbeat_task: Optional[asyncio.Task] = None
227
+ self._heartbeat_interval: float = 30.0 # seconds, updated by Hello
228
+ self._session_id: Optional[str] = None
229
+ self._last_seq: Optional[int] = None
230
+ self._chat_type_map: Dict[str, str] = {} # chat_id → "c2c"|"group"|"guild"|"dm"
231
+
232
+ # Request/response correlation
233
+ self._pending_responses: Dict[str, asyncio.Future] = {}
234
+ self._seen_messages: Dict[str, float] = {}
235
+
236
+ # Last inbound message ID per chat — used by send_typing
237
+ self._last_msg_id: Dict[str, str] = {}
238
+ # Typing debounce: chat_id → last send_typing timestamp
239
+ self._typing_sent_at: Dict[str, float] = {}
240
+
241
+ # Token cache
242
+ self._access_token: Optional[str] = None
243
+ self._token_expires_at: float = 0.0
244
+ self._token_lock = asyncio.Lock()
245
+
246
+ # Upload cache: content_hash -> {file_info, file_uuid, expires_at}
247
+ self._upload_cache: Dict[str, Dict[str, Any]] = {}
248
+
249
+ # Inline-keyboard interaction routing. The callback (if set) is invoked
250
+ # for every INTERACTION_CREATE event after the adapter has already
251
+ # ACKed it. Callers (gateway wiring for approvals / update prompts)
252
+ # register via set_interaction_callback().
253
+ self._interaction_callback: Optional[
254
+ Callable[[InteractionEvent], Awaitable[None]]
255
+ ] = None
256
+
257
+ # Default interaction dispatcher: routes approval-button clicks to
258
+ # tools.approval.resolve_gateway_approval() and update-prompt clicks
259
+ # to ~/.hermes/.update_response. Set here so the cross-adapter gateway
260
+ # contract (send_exec_approval / send_update_prompt) works out of the
261
+ # box; callers can override with set_interaction_callback(None) or
262
+ # register a custom handler.
263
+ self._interaction_callback = self._default_interaction_dispatch
264
+
265
+ # ------------------------------------------------------------------
266
+ # Properties
267
+ # ------------------------------------------------------------------
268
+
269
+ @property
270
+ def name(self) -> str:
271
+ return "QQBot"
272
+
273
+ # ------------------------------------------------------------------
274
+ # Connection lifecycle
275
+ # ------------------------------------------------------------------
276
+
277
+ async def connect(self) -> bool:
278
+ """Authenticate, obtain gateway URL, and open the WebSocket."""
279
+ if not AIOHTTP_AVAILABLE:
280
+ message = "QQ startup failed: aiohttp not installed"
281
+ self._set_fatal_error("qq_missing_dependency", message, retryable=True)
282
+ logger.warning("[%s] %s. Run: pip install aiohttp", self._log_tag, message)
283
+ return False
284
+ if not HTTPX_AVAILABLE:
285
+ message = "QQ startup failed: httpx not installed"
286
+ self._set_fatal_error("qq_missing_dependency", message, retryable=True)
287
+ logger.warning("[%s] %s. Run: pip install httpx", self._log_tag, message)
288
+ return False
289
+ if not self._app_id or not self._client_secret:
290
+ message = "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required"
291
+ self._set_fatal_error("qq_missing_credentials", message, retryable=True)
292
+ logger.warning("[%s] %s", self._log_tag, message)
293
+ return False
294
+
295
+ # Prevent duplicate connections with the same credentials
296
+ if not self._acquire_platform_lock("qqbot-appid", self._app_id, "QQBot app ID"):
297
+ return False
298
+
299
+ try:
300
+ # Tighter keepalive pool so idle CLOSE_WAIT sockets drain
301
+ # faster behind proxies like Cloudflare Warp (#18451).
302
+ from gateway.platforms._http_client_limits import platform_httpx_limits
303
+ self._http_client = httpx.AsyncClient(
304
+ timeout=30.0,
305
+ follow_redirects=True,
306
+ event_hooks={"response": [_ssrf_redirect_guard]},
307
+ limits=platform_httpx_limits(),
308
+ )
309
+
310
+ # 1. Get access token
311
+ await self._ensure_token()
312
+
313
+ # 2. Get WebSocket gateway URL
314
+ gateway_url = await self._get_gateway_url()
315
+ logger.info("[%s] Gateway URL: %s", self._log_tag, gateway_url)
316
+
317
+ # 3. Open WebSocket
318
+ await self._open_ws(gateway_url)
319
+
320
+ # 4. Start listeners
321
+ self._listen_task = asyncio.create_task(self._listen_loop())
322
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
323
+ self._mark_connected()
324
+ logger.info("[%s] Connected", self._log_tag)
325
+ return True
326
+ except Exception as exc:
327
+ message = f"QQ startup failed: {exc}"
328
+ self._set_fatal_error("qq_connect_error", message, retryable=True)
329
+ logger.error("[%s] %s", self._log_tag, message, exc_info=True)
330
+ await self._cleanup()
331
+ self._release_platform_lock()
332
+ return False
333
+
334
+ async def disconnect(self) -> None:
335
+ """Close all connections and stop listeners."""
336
+ self._running = False
337
+ self._mark_disconnected()
338
+
339
+ if self._listen_task:
340
+ self._listen_task.cancel()
341
+ try:
342
+ await self._listen_task
343
+ except asyncio.CancelledError:
344
+ pass
345
+ self._listen_task = None
346
+
347
+ if self._heartbeat_task:
348
+ self._heartbeat_task.cancel()
349
+ try:
350
+ await self._heartbeat_task
351
+ except asyncio.CancelledError:
352
+ pass
353
+ self._heartbeat_task = None
354
+
355
+ await self._cleanup()
356
+ self._release_platform_lock()
357
+ logger.info("[%s] Disconnected", self._log_tag)
358
+
359
+ async def _cleanup(self) -> None:
360
+ """Close WebSocket, HTTP session, and client."""
361
+ if self._ws and not self._ws.closed:
362
+ await self._ws.close()
363
+ self._ws = None
364
+
365
+ if self._session and not self._session.closed:
366
+ await self._session.close()
367
+ self._session = None
368
+
369
+ if self._http_client:
370
+ await self._http_client.aclose()
371
+ self._http_client = None
372
+
373
+ # Fail pending
374
+ for fut in self._pending_responses.values():
375
+ if not fut.done():
376
+ fut.set_exception(RuntimeError("Disconnected"))
377
+ self._pending_responses.clear()
378
+
379
+ # ------------------------------------------------------------------
380
+ # Token management
381
+ # ------------------------------------------------------------------
382
+
383
+ async def _ensure_token(self) -> str:
384
+ """Return a valid access token, refreshing if needed (with singleflight)."""
385
+ if self._access_token and time.time() < self._token_expires_at - 60:
386
+ return self._access_token
387
+
388
+ async with self._token_lock:
389
+ # Double-check after acquiring lock
390
+ if self._access_token and time.time() < self._token_expires_at - 60:
391
+ return self._access_token
392
+
393
+ try:
394
+ resp = await self._http_client.post(
395
+ TOKEN_URL,
396
+ json={"appId": self._app_id, "clientSecret": self._client_secret},
397
+ timeout=DEFAULT_API_TIMEOUT,
398
+ )
399
+ resp.raise_for_status()
400
+ data = resp.json()
401
+ except Exception as exc:
402
+ raise RuntimeError(f"Failed to get QQ Bot access token: {exc}") from exc
403
+
404
+ token = data.get("access_token")
405
+ if not token:
406
+ raise RuntimeError(
407
+ f"QQ Bot token response missing access_token: {data}"
408
+ )
409
+
410
+ expires_in = int(data.get("expires_in", 7200))
411
+ self._access_token = token
412
+ self._token_expires_at = time.time() + expires_in
413
+ logger.info(
414
+ "[%s] Access token refreshed, expires in %ds", self._log_tag, expires_in
415
+ )
416
+ return self._access_token
417
+
418
+ async def _get_gateway_url(self) -> str:
419
+ """Fetch the WebSocket gateway URL from the REST API."""
420
+ token = await self._ensure_token()
421
+ try:
422
+ resp = await self._http_client.get(
423
+ f"{API_BASE}{GATEWAY_URL_PATH}",
424
+ headers={
425
+ "Authorization": f"QQBot {token}",
426
+ "User-Agent": build_user_agent(),
427
+ },
428
+ timeout=DEFAULT_API_TIMEOUT,
429
+ )
430
+ resp.raise_for_status()
431
+ data = resp.json()
432
+ except Exception as exc:
433
+ raise RuntimeError(f"Failed to get QQ Bot gateway URL: {exc}") from exc
434
+
435
+ url = data.get("url")
436
+ if not url:
437
+ raise RuntimeError(f"QQ Bot gateway response missing url: {data}")
438
+ return url
439
+
440
+ # ------------------------------------------------------------------
441
+ # WebSocket lifecycle
442
+ # ------------------------------------------------------------------
443
+
444
+ async def _open_ws(self, gateway_url: str) -> None:
445
+ """Open a WebSocket connection to the QQ Bot gateway."""
446
+ # Only clean up WebSocket resources — keep _http_client alive for REST API calls.
447
+ if self._ws and not self._ws.closed:
448
+ await self._ws.close()
449
+ self._ws = None
450
+ if self._session and not self._session.closed:
451
+ await self._session.close()
452
+ self._session = None
453
+
454
+ # Honor WSL proxy env for QQ WebSocket. Hermes upgrades overwrite this
455
+ # local patch, so QQ can regress to direct-connect timeouts after update.
456
+ self._session = aiohttp.ClientSession(trust_env=True)
457
+ ws_proxy = (
458
+ os.getenv("WSS_PROXY")
459
+ or os.getenv("wss_proxy")
460
+ or os.getenv("HTTPS_PROXY")
461
+ or os.getenv("https_proxy")
462
+ or os.getenv("ALL_PROXY")
463
+ or os.getenv("all_proxy")
464
+ )
465
+ self._ws = await self._session.ws_connect(
466
+ gateway_url,
467
+ headers={
468
+ "User-Agent": build_user_agent(),
469
+ },
470
+ timeout=CONNECT_TIMEOUT_SECONDS,
471
+ proxy=ws_proxy,
472
+ )
473
+ logger.info("[%s] WebSocket connected to %s", self._log_tag, gateway_url)
474
+
475
+ async def _listen_loop(self) -> None:
476
+ """Read WebSocket events and reconnect on errors.
477
+
478
+ Close code handling follows the OpenClaw qqbot reference implementation:
479
+ 4004 → invalid token, refresh and reconnect
480
+ 4006/4007/4009 → session invalid, clear session and re-identify
481
+ 4008 → rate limited, back off 60s
482
+ 4914 → bot offline/sandbox, stop reconnecting
483
+ 4915 → bot banned, stop reconnecting
484
+ """
485
+ backoff_idx = 0
486
+ connect_time = 0.0
487
+ quick_disconnect_count = 0
488
+
489
+ while self._running:
490
+ try:
491
+ connect_time = time.monotonic()
492
+ await self._read_events()
493
+ backoff_idx = 0
494
+ quick_disconnect_count = 0
495
+ except asyncio.CancelledError:
496
+ return
497
+ except QQCloseError as exc:
498
+ if not self._running:
499
+ return
500
+
501
+ code = exc.code
502
+ logger.warning(
503
+ "[%s] WebSocket closed: code=%s reason=%s",
504
+ self._log_tag,
505
+ code,
506
+ exc.reason,
507
+ )
508
+
509
+ # Quick disconnect detection (permission issues, misconfiguration)
510
+ duration = time.monotonic() - connect_time
511
+ if duration < QUICK_DISCONNECT_THRESHOLD and connect_time > 0:
512
+ quick_disconnect_count += 1
513
+ logger.info(
514
+ "[%s] Quick disconnect (%.1fs), count: %d",
515
+ self._log_tag,
516
+ duration,
517
+ quick_disconnect_count,
518
+ )
519
+ if quick_disconnect_count >= MAX_QUICK_DISCONNECT_COUNT:
520
+ logger.error(
521
+ "[%s] Too many quick disconnects. "
522
+ "Check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform",
523
+ self._log_tag,
524
+ )
525
+ self._set_fatal_error(
526
+ "qq_quick_disconnect",
527
+ "Too many quick disconnects — check bot permissions",
528
+ retryable=True,
529
+ )
530
+ return
531
+ else:
532
+ quick_disconnect_count = 0
533
+
534
+ self._mark_transport_disconnected()
535
+ self._fail_pending("Connection closed")
536
+
537
+ # Stop reconnecting for fatal codes
538
+ if code in {4914, 4915}:
539
+ desc = "offline/sandbox-only" if code == 4914 else "banned"
540
+ logger.error(
541
+ "[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc
542
+ )
543
+ self._set_fatal_error(
544
+ f"qq_{desc}", f"Bot is {desc}", retryable=False
545
+ )
546
+ return
547
+
548
+ # Rate limited
549
+ if code == 4008:
550
+ logger.info(
551
+ "[%s] Rate limited (4008), waiting %ds",
552
+ self._log_tag,
553
+ RATE_LIMIT_DELAY,
554
+ )
555
+ if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
556
+ self._mark_disconnected()
557
+ return
558
+ await asyncio.sleep(RATE_LIMIT_DELAY)
559
+ if await self._reconnect(backoff_idx):
560
+ backoff_idx = 0
561
+ quick_disconnect_count = 0
562
+ else:
563
+ backoff_idx += 1
564
+ continue
565
+
566
+ # Token invalid → clear cached token so _ensure_token() refreshes
567
+ if code == 4004:
568
+ logger.info(
569
+ "[%s] Invalid token (4004), will refresh and reconnect",
570
+ self._log_tag,
571
+ )
572
+ self._access_token = None
573
+ self._token_expires_at = 0.0
574
+
575
+ # Session invalid → clear session, will re-identify on next Hello
576
+ if code in {
577
+ 4006,
578
+ 4007,
579
+ 4009,
580
+ 4900,
581
+ 4901,
582
+ 4902,
583
+ 4903,
584
+ 4904,
585
+ 4905,
586
+ 4906,
587
+ 4907,
588
+ 4908,
589
+ 4909,
590
+ 4910,
591
+ 4911,
592
+ 4912,
593
+ 4913,
594
+ }:
595
+ logger.info(
596
+ "[%s] Session error (%d), clearing session for re-identify",
597
+ self._log_tag,
598
+ code,
599
+ )
600
+ self._session_id = None
601
+ self._last_seq = None
602
+
603
+ if await self._reconnect(backoff_idx):
604
+ backoff_idx = 0
605
+ quick_disconnect_count = 0
606
+ else:
607
+ backoff_idx += 1
608
+ if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
609
+ logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag)
610
+ self._mark_disconnected()
611
+ return
612
+
613
+ except Exception as exc:
614
+ if not self._running:
615
+ return
616
+ logger.warning("[%s] WebSocket error: %s", self._log_tag, exc)
617
+ self._mark_transport_disconnected()
618
+ self._fail_pending("Connection interrupted")
619
+
620
+ if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
621
+ logger.error("[%s] Max reconnect attempts reached", self._log_tag)
622
+ self._mark_disconnected()
623
+ return
624
+
625
+ if await self._reconnect(backoff_idx):
626
+ backoff_idx = 0
627
+ quick_disconnect_count = 0
628
+ else:
629
+ backoff_idx += 1
630
+
631
+ async def _reconnect(self, backoff_idx: int) -> bool:
632
+ """Attempt to reconnect the WebSocket. Returns True on success."""
633
+ delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
634
+ logger.info(
635
+ "[%s] Reconnecting in %ds (attempt %d)...",
636
+ self._log_tag,
637
+ delay,
638
+ backoff_idx + 1,
639
+ )
640
+ await asyncio.sleep(delay)
641
+
642
+ self._heartbeat_interval = 30.0 # reset until Hello
643
+ try:
644
+ await self._ensure_token()
645
+ gateway_url = await self._get_gateway_url()
646
+ await self._open_ws(gateway_url)
647
+ self._mark_connected()
648
+ logger.info("[%s] Reconnected", self._log_tag)
649
+ return True
650
+ except Exception as exc:
651
+ logger.warning("[%s] Reconnect failed: %s", self._log_tag, exc)
652
+ return False
653
+
654
+ async def _read_events(self) -> None:
655
+ """Read WebSocket frames until connection closes."""
656
+ if not self._ws:
657
+ raise RuntimeError("WebSocket not connected")
658
+
659
+ while self._running and self._ws and not self._ws.closed:
660
+ msg = await self._ws.receive()
661
+ if msg.type == aiohttp.WSMsgType.TEXT:
662
+ payload = self._parse_json(msg.data)
663
+ if payload:
664
+ self._dispatch_payload(payload)
665
+ elif msg.type in {aiohttp.WSMsgType.PING,}:
666
+ # aiohttp auto-replies with PONG
667
+ pass
668
+ elif msg.type == aiohttp.WSMsgType.CLOSE:
669
+ raise QQCloseError(msg.data, msg.extra)
670
+ elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}:
671
+ raise RuntimeError("WebSocket closed")
672
+
673
+ async def _heartbeat_loop(self) -> None:
674
+ """Send periodic heartbeats (QQ Gateway expects op 1 heartbeat with latest seq).
675
+
676
+ The interval is set from the Hello (op 10) event's heartbeat_interval.
677
+ QQ's default is ~41s; we send at 80% of the interval to stay safe.
678
+ """
679
+ try:
680
+ while self._running:
681
+ await asyncio.sleep(self._heartbeat_interval)
682
+ if not self._ws or self._ws.closed:
683
+ continue
684
+ try:
685
+ # d should be the latest sequence number received, or null
686
+ await self._ws.send_json({"op": 1, "d": self._last_seq})
687
+ except Exception as exc:
688
+ logger.debug("[%s] Heartbeat failed: %s", self._log_tag, exc)
689
+ except asyncio.CancelledError:
690
+ pass
691
+
692
+ async def _send_identify(self) -> None:
693
+ """Send op 2 Identify to authenticate the WebSocket connection.
694
+
695
+ After receiving op 10 Hello, the client must send op 2 Identify with
696
+ the bot token and intents. On success the server replies with a
697
+ READY dispatch event.
698
+
699
+ Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/reference.html
700
+ """
701
+ token = await self._ensure_token()
702
+ identify_payload = {
703
+ "op": 2,
704
+ "d": {
705
+ "token": f"QQBot {token}",
706
+ "intents": (1 << 25)
707
+ | (1 << 30)
708
+ | (
709
+ 1 << 12
710
+ ), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE
711
+ "shard": [0, 1],
712
+ "properties": {
713
+ "$os": "macOS",
714
+ "$browser": "hermes-agent",
715
+ "$device": "hermes-agent",
716
+ },
717
+ },
718
+ }
719
+ try:
720
+ if self._ws and not self._ws.closed:
721
+ await self._ws.send_json(identify_payload)
722
+ logger.info("[%s] Identify sent", self._log_tag)
723
+ else:
724
+ logger.warning(
725
+ "[%s] Cannot send Identify: WebSocket not connected", self._log_tag
726
+ )
727
+ except Exception as exc:
728
+ logger.error("[%s] Failed to send Identify: %s", self._log_tag, exc)
729
+
730
+ async def _send_resume(self) -> None:
731
+ """Send op 6 Resume to re-authenticate after a reconnection.
732
+
733
+ Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/reference.html
734
+ """
735
+ token = await self._ensure_token()
736
+ resume_payload = {
737
+ "op": 6,
738
+ "d": {
739
+ "token": f"QQBot {token}",
740
+ "session_id": self._session_id,
741
+ "seq": self._last_seq,
742
+ },
743
+ }
744
+ try:
745
+ if self._ws and not self._ws.closed:
746
+ await self._ws.send_json(resume_payload)
747
+ logger.info(
748
+ "[%s] Resume sent (session_id=%s, seq=%s)",
749
+ self._log_tag,
750
+ self._session_id,
751
+ self._last_seq,
752
+ )
753
+ else:
754
+ logger.warning(
755
+ "[%s] Cannot send Resume: WebSocket not connected", self._log_tag
756
+ )
757
+ except Exception as exc:
758
+ logger.error("[%s] Failed to send Resume: %s", self._log_tag, exc)
759
+ # If resume fails, clear session and fall back to identify on next Hello
760
+ self._session_id = None
761
+ self._last_seq = None
762
+
763
+ @staticmethod
764
+ def _create_task(coro):
765
+ """Schedule a coroutine, silently skipping if no event loop is running.
766
+
767
+ This avoids ``RuntimeError: no running event loop`` when tests call
768
+ ``_dispatch_payload`` synchronously outside of ``asyncio.run()``.
769
+ """
770
+ try:
771
+ loop = asyncio.get_running_loop()
772
+ return loop.create_task(coro)
773
+ except RuntimeError:
774
+ return None
775
+
776
+ def _dispatch_payload(self, payload: Dict[str, Any]) -> None:
777
+ """Route inbound WebSocket payloads (dispatch synchronously, spawn async handlers)."""
778
+ op = payload.get("op")
779
+ t = payload.get("t")
780
+ s = payload.get("s")
781
+ d = payload.get("d")
782
+ if isinstance(s, int) and (self._last_seq is None or s > self._last_seq):
783
+ self._last_seq = s
784
+
785
+ # op 10 = Hello (heartbeat interval) — must reply with Identify/Resume
786
+ if op == 10:
787
+ d_data = d if isinstance(d, dict) else {}
788
+ interval_ms = d_data.get("heartbeat_interval", 30000)
789
+ # Send heartbeats at 80% of the server interval to stay safe
790
+ self._heartbeat_interval = interval_ms / 1000.0 * 0.8
791
+ logger.debug(
792
+ "[%s] Hello received, heartbeat_interval=%dms (sending every %.1fs)",
793
+ self._log_tag,
794
+ interval_ms,
795
+ self._heartbeat_interval,
796
+ )
797
+ # Authenticate: send Resume if we have a session, else Identify.
798
+ # Use _create_task which is safe when no event loop is running (tests).
799
+ if self._session_id and self._last_seq is not None:
800
+ self._create_task(self._send_resume())
801
+ else:
802
+ self._create_task(self._send_identify())
803
+ return
804
+
805
+ # op 0 = Dispatch
806
+ if op == 0 and t:
807
+ if t == "READY":
808
+ self._handle_ready(d)
809
+ elif t == "RESUMED":
810
+ logger.info("[%s] Session resumed", self._log_tag)
811
+ elif t in {
812
+ "C2C_MESSAGE_CREATE",
813
+ "GROUP_AT_MESSAGE_CREATE",
814
+ "DIRECT_MESSAGE_CREATE",
815
+ "GUILD_MESSAGE_CREATE",
816
+ "GUILD_AT_MESSAGE_CREATE",
817
+ }:
818
+ asyncio.create_task(self._on_message(t, d))
819
+ elif t == "INTERACTION_CREATE":
820
+ self._create_task(self._on_interaction(d))
821
+ else:
822
+ logger.debug("[%s] Unhandled dispatch: %s", self._log_tag, t)
823
+ return
824
+
825
+ # op 11 = Heartbeat ACK
826
+ if op == 11:
827
+ return
828
+
829
+ logger.debug("[%s] Unknown op: %s", self._log_tag, op)
830
+
831
+ def _handle_ready(self, d: Any) -> None:
832
+ """Handle the READY event — store session_id for resume."""
833
+ if isinstance(d, dict):
834
+ self._session_id = d.get("session_id")
835
+ logger.info("[%s] Ready, session_id=%s", self._log_tag, self._session_id)
836
+
837
+ # ------------------------------------------------------------------
838
+ # JSON helpers
839
+ # ------------------------------------------------------------------
840
+
841
+ @staticmethod
842
+ def _parse_json(raw: Any) -> Optional[Dict[str, Any]]:
843
+ try:
844
+ payload = json.loads(raw)
845
+ except Exception:
846
+ logger.warning("[QQBot] Failed to parse JSON: %r", raw)
847
+ return None
848
+ return payload if isinstance(payload, dict) else None
849
+
850
+ @staticmethod
851
+ def _next_msg_seq(msg_id: str) -> int:
852
+ """Generate a message sequence number in 0..65535 range."""
853
+ time_part = int(time.time()) % 100000000
854
+ rand = int(uuid.uuid4().hex[:4], 16)
855
+ return (time_part ^ rand) % 65536
856
+
857
+ # ------------------------------------------------------------------
858
+ # Inbound message handling
859
+ # ------------------------------------------------------------------
860
+
861
+ async def handle_message(self, event: MessageEvent) -> None:
862
+ """Cache the last message ID per chat, then delegate to base."""
863
+ if event.message_id and event.source.chat_id:
864
+ self._last_msg_id[event.source.chat_id] = event.message_id
865
+ await super().handle_message(event)
866
+
867
+ async def _on_message(self, event_type: str, d: Any) -> None:
868
+ """Process an inbound QQ Bot message event."""
869
+ if not isinstance(d, dict):
870
+ return
871
+
872
+ # Extract common fields
873
+ msg_id = str(d.get("id", ""))
874
+ if not msg_id or self._is_duplicate(msg_id):
875
+ logger.debug(
876
+ "[%s] Duplicate or missing message id: %s", self._log_tag, msg_id
877
+ )
878
+ return
879
+
880
+ timestamp = str(d.get("timestamp", ""))
881
+ content = str(d.get("content", "")).strip()
882
+ author = d.get("author") if isinstance(d.get("author"), dict) else {}
883
+
884
+ # Route by event type
885
+ if event_type == "C2C_MESSAGE_CREATE":
886
+ await self._handle_c2c_message(d, msg_id, content, author, timestamp)
887
+ elif event_type in {"GROUP_AT_MESSAGE_CREATE",}:
888
+ await self._handle_group_message(d, msg_id, content, author, timestamp)
889
+ elif event_type in {"GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE"}:
890
+ await self._handle_guild_message(d, msg_id, content, author, timestamp)
891
+ elif event_type == "DIRECT_MESSAGE_CREATE":
892
+ await self._handle_dm_message(d, msg_id, content, author, timestamp)
893
+
894
+ # ------------------------------------------------------------------
895
+ # Inline-keyboard interactions (INTERACTION_CREATE)
896
+ # ------------------------------------------------------------------
897
+
898
+ def set_interaction_callback(
899
+ self,
900
+ callback: Optional[Callable[[InteractionEvent], Awaitable[None]]],
901
+ ) -> None:
902
+ """Register (or clear) the interaction callback.
903
+
904
+ Invoked once per ``INTERACTION_CREATE`` event *after* the adapter has
905
+ ACKed the interaction. The callback is responsible for routing the
906
+ button click to the right subsystem (approval resolver, update-prompt
907
+ resolver, etc.) based on the ``button_data`` payload.
908
+ """
909
+ self._interaction_callback = callback
910
+
911
+ async def _on_interaction(self, d: Any) -> None:
912
+ """Handle an ``INTERACTION_CREATE`` event.
913
+
914
+ Responsibilities:
915
+
916
+ 1. Parse the raw payload into an :class:`InteractionEvent`.
917
+ 2. ACK the interaction (``PUT /interactions/{id}``) so the client
918
+ stops showing a loading indicator on the button.
919
+ 3. Dispatch to the registered interaction callback, if any.
920
+ """
921
+ if not isinstance(d, dict):
922
+ return
923
+ try:
924
+ event = parse_interaction_event(d)
925
+ except Exception as exc:
926
+ logger.warning(
927
+ "[%s] Failed to parse INTERACTION_CREATE: %s", self._log_tag, exc
928
+ )
929
+ return
930
+
931
+ if not event.id:
932
+ logger.warning(
933
+ "[%s] INTERACTION_CREATE missing id, skipping ACK", self._log_tag
934
+ )
935
+ return
936
+
937
+ # ACK the interaction promptly — per the QQ docs the client will show
938
+ # an error icon on the button if we don't respond quickly.
939
+ try:
940
+ await self._acknowledge_interaction(event.id)
941
+ except Exception as exc:
942
+ logger.warning(
943
+ "[%s] Failed to ACK interaction %s: %s",
944
+ self._log_tag, event.id, exc,
945
+ )
946
+
947
+ logger.info(
948
+ "[%s] Interaction: scene=%s button_data=%r operator=%s",
949
+ self._log_tag, event.scene, event.button_data, event.operator_openid,
950
+ )
951
+
952
+ callback = self._interaction_callback
953
+ if callback is None:
954
+ logger.debug(
955
+ "[%s] No interaction callback registered; dropping button "
956
+ "click %r",
957
+ self._log_tag, event.button_data,
958
+ )
959
+ return
960
+ try:
961
+ await callback(event)
962
+ except Exception as exc:
963
+ logger.error(
964
+ "[%s] Interaction callback raised: %s",
965
+ self._log_tag, exc, exc_info=True,
966
+ )
967
+
968
+ async def _acknowledge_interaction(
969
+ self,
970
+ interaction_id: str,
971
+ code: int = 0,
972
+ ) -> None:
973
+ """ACK a button interaction via ``PUT /interactions/{id}``.
974
+
975
+ :param interaction_id: The ``id`` field from the
976
+ ``INTERACTION_CREATE`` event.
977
+ :param code: Response code (``0`` = success).
978
+ """
979
+ if not self._http_client:
980
+ raise RuntimeError("HTTP client not initialized — not connected?")
981
+ token = await self._ensure_token()
982
+ headers = {
983
+ "Authorization": f"QQBot {token}",
984
+ "Content-Type": "application/json",
985
+ "User-Agent": build_user_agent(),
986
+ }
987
+ resp = await self._http_client.put(
988
+ f"{API_BASE}/interactions/{interaction_id}",
989
+ headers=headers,
990
+ json={"code": code},
991
+ timeout=DEFAULT_API_TIMEOUT,
992
+ )
993
+ if resp.status_code >= 400:
994
+ raise RuntimeError(
995
+ f"Interaction ACK failed [{resp.status_code}]: "
996
+ f"{resp.text[:200]}"
997
+ )
998
+
999
+ # Mapping from QQ keyboard button decisions → the ``choice`` vocabulary
1000
+ # accepted by ``tools.approval.resolve_gateway_approval``. QQ's 3-button
1001
+ # layout (mobile-space constraint) collapses "session" and "always" into
1002
+ # a single "always" button; users wanting session-only approval can fall
1003
+ # back to the ``/approve session`` text command.
1004
+ _APPROVAL_BUTTON_TO_CHOICE = {
1005
+ "allow-once": "once",
1006
+ "allow-always": "always",
1007
+ "deny": "deny",
1008
+ }
1009
+
1010
+ async def _default_interaction_dispatch(
1011
+ self,
1012
+ event: InteractionEvent,
1013
+ ) -> None:
1014
+ """Route ``INTERACTION_CREATE`` button clicks to the right subsystem.
1015
+
1016
+ - ``approve:<session_key>:<decision>`` →
1017
+ :func:`tools.approval.resolve_gateway_approval`
1018
+ (unblocks the agent thread waiting on a dangerous-command approval).
1019
+ - ``update_prompt:<answer>`` →
1020
+ writes the answer to ``~/.hermes/.update_response`` for the
1021
+ detached ``hermes update --gateway`` process to consume.
1022
+ - Anything else is logged at DEBUG and ignored.
1023
+
1024
+ Installed as the adapter's default interaction callback in
1025
+ ``__init__``. Callers can replace via
1026
+ :meth:`set_interaction_callback` to route clicks elsewhere (or pass
1027
+ ``None`` to drop them entirely).
1028
+ """
1029
+ button_data = event.button_data
1030
+ if not button_data:
1031
+ return
1032
+
1033
+ approval = parse_approval_button_data(button_data)
1034
+ if approval is not None:
1035
+ session_key, decision = approval
1036
+ choice = self._APPROVAL_BUTTON_TO_CHOICE.get(decision)
1037
+ if choice is None:
1038
+ logger.warning(
1039
+ "[%s] Unknown approval decision %r (session=%s)",
1040
+ self._log_tag, decision, session_key,
1041
+ )
1042
+ return
1043
+ try:
1044
+ # Import lazily to keep the adapter importable in tests that
1045
+ # don't exercise the approval subsystem.
1046
+ from tools.approval import resolve_gateway_approval
1047
+ count = resolve_gateway_approval(session_key, choice)
1048
+ logger.info(
1049
+ "[%s] Button resolved %d approval(s) for session %s "
1050
+ "(choice=%s, operator=%s)",
1051
+ self._log_tag, count, session_key, choice,
1052
+ event.operator_openid,
1053
+ )
1054
+ except Exception as exc:
1055
+ logger.error(
1056
+ "[%s] resolve_gateway_approval failed for session %s: %s",
1057
+ self._log_tag, session_key, exc,
1058
+ )
1059
+ return
1060
+
1061
+ update_answer = parse_update_prompt_button_data(button_data)
1062
+ if update_answer is not None:
1063
+ self._write_update_response(update_answer, event.operator_openid)
1064
+ return
1065
+
1066
+ logger.debug(
1067
+ "[%s] Unrecognised button_data %r from interaction %s",
1068
+ self._log_tag, button_data, event.id,
1069
+ )
1070
+
1071
+ @staticmethod
1072
+ def _write_update_response(answer: str, operator: str = "") -> None:
1073
+ """Atomically write the update-prompt answer to ``.update_response``.
1074
+
1075
+ Mirrors the Discord / Telegram / Feishu adapters: the detached
1076
+ ``hermes update --gateway`` watcher polls this file for a ``y``/``n``
1077
+ response to its interactive prompts (stash-restore, config migration).
1078
+ Writes via ``tmp + rename`` so a partial write can't fool the reader.
1079
+ """
1080
+ try:
1081
+ from calvyn_constants import get_hermes_home
1082
+ home = get_hermes_home()
1083
+ response_path = home / ".update_response"
1084
+ tmp = response_path.with_suffix(".tmp")
1085
+ tmp.write_text(answer)
1086
+ tmp.replace(response_path)
1087
+ logger.info(
1088
+ "QQ update prompt answered %r by %s",
1089
+ answer, operator or "(unknown)",
1090
+ )
1091
+ except Exception as exc:
1092
+ logger.error("Failed to write update response: %s", exc)
1093
+
1094
+ async def _handle_c2c_message(
1095
+ self,
1096
+ d: Dict[str, Any],
1097
+ msg_id: str,
1098
+ content: str,
1099
+ author: Dict[str, Any],
1100
+ timestamp: str,
1101
+ ) -> None:
1102
+ """Handle a C2C (private) message event."""
1103
+ user_openid = str(author.get("user_openid", ""))
1104
+ if not user_openid:
1105
+ return
1106
+ if not self._is_dm_allowed(user_openid):
1107
+ return
1108
+
1109
+ text = content
1110
+ attachments_raw = d.get("attachments")
1111
+ logger.info(
1112
+ "[%s] C2C message: id=%s content=%r attachments=%s",
1113
+ self._log_tag,
1114
+ msg_id,
1115
+ content[:50] if content else "",
1116
+ (
1117
+ f"{len(attachments_raw) if isinstance(attachments_raw, list) else 0} items"
1118
+ if attachments_raw
1119
+ else "None"
1120
+ ),
1121
+ )
1122
+ if attachments_raw and isinstance(attachments_raw, list):
1123
+ for _i, _att in enumerate(attachments_raw):
1124
+ if isinstance(_att, dict):
1125
+ logger.info(
1126
+ "[%s] attachment[%d]: content_type=%s url=%s filename=%s",
1127
+ self._log_tag,
1128
+ _i,
1129
+ _att.get("content_type", ""),
1130
+ str(_att.get("url", ""))[:80],
1131
+ _att.get("filename", ""),
1132
+ )
1133
+
1134
+ # Process all attachments uniformly (images, voice, files)
1135
+ att_result = await self._process_attachments(attachments_raw)
1136
+ image_urls = att_result["image_urls"]
1137
+ image_media_types = att_result["image_media_types"]
1138
+ voice_transcripts = att_result["voice_transcripts"]
1139
+ attachment_info = att_result["attachment_info"]
1140
+
1141
+ # Append voice transcripts to the text body
1142
+ if voice_transcripts:
1143
+ voice_block = "\n".join(voice_transcripts)
1144
+ text = (
1145
+ (text + "\n\n" + voice_block).strip() if text.strip() else voice_block
1146
+ )
1147
+ # Append non-media attachment info
1148
+ if attachment_info:
1149
+ text = (
1150
+ (text + "\n\n" + attachment_info).strip()
1151
+ if text.strip()
1152
+ else attachment_info
1153
+ )
1154
+
1155
+ logger.info(
1156
+ "[%s] After processing: images=%d, voice=%d",
1157
+ self._log_tag,
1158
+ len(image_urls),
1159
+ len(voice_transcripts),
1160
+ )
1161
+
1162
+ # Merge any quoted-message context (message_type=103 → msg_elements[0]).
1163
+ quoted = await self._process_quoted_context(d)
1164
+ text = self._merge_quote_into(text, quoted["quote_block"])
1165
+ if quoted["image_urls"]:
1166
+ image_urls = image_urls + quoted["image_urls"]
1167
+ image_media_types = image_media_types + quoted["image_media_types"]
1168
+
1169
+ if not text.strip() and not image_urls:
1170
+ return
1171
+
1172
+ self._chat_type_map[user_openid] = "c2c"
1173
+ event = MessageEvent(
1174
+ source=self.build_source(
1175
+ chat_id=user_openid,
1176
+ user_id=user_openid,
1177
+ chat_type="dm",
1178
+ ),
1179
+ text=text,
1180
+ message_type=self._detect_message_type(image_urls, image_media_types),
1181
+ raw_message=d,
1182
+ message_id=msg_id,
1183
+ media_urls=image_urls,
1184
+ media_types=image_media_types,
1185
+ timestamp=self._parse_qq_timestamp(timestamp),
1186
+ )
1187
+ await self.handle_message(event)
1188
+
1189
+ async def _handle_group_message(
1190
+ self,
1191
+ d: Dict[str, Any],
1192
+ msg_id: str,
1193
+ content: str,
1194
+ author: Dict[str, Any],
1195
+ timestamp: str,
1196
+ ) -> None:
1197
+ """Handle a group @-message event."""
1198
+ group_openid = str(d.get("group_openid", ""))
1199
+ if not group_openid:
1200
+ return
1201
+ if not self._is_group_allowed(
1202
+ group_openid, str(author.get("member_openid", ""))
1203
+ ):
1204
+ return
1205
+
1206
+ # Strip the @bot mention prefix from content
1207
+ text = self._strip_at_mention(content)
1208
+ att_result = await self._process_attachments(d.get("attachments"))
1209
+ image_urls = att_result["image_urls"]
1210
+ image_media_types = att_result["image_media_types"]
1211
+ voice_transcripts = att_result["voice_transcripts"]
1212
+ attachment_info = att_result["attachment_info"]
1213
+
1214
+ # Append voice transcripts
1215
+ if voice_transcripts:
1216
+ voice_block = "\n".join(voice_transcripts)
1217
+ text = (
1218
+ (text + "\n\n" + voice_block).strip() if text.strip() else voice_block
1219
+ )
1220
+ if attachment_info:
1221
+ text = (
1222
+ (text + "\n\n" + attachment_info).strip()
1223
+ if text.strip()
1224
+ else attachment_info
1225
+ )
1226
+
1227
+ # Merge any quoted-message context (message_type=103 → msg_elements[0]).
1228
+ quoted = await self._process_quoted_context(d)
1229
+ text = self._merge_quote_into(text, quoted["quote_block"])
1230
+ if quoted["image_urls"]:
1231
+ image_urls = image_urls + quoted["image_urls"]
1232
+ image_media_types = image_media_types + quoted["image_media_types"]
1233
+
1234
+ if not text.strip() and not image_urls:
1235
+ return
1236
+
1237
+ self._chat_type_map[group_openid] = "group"
1238
+ event = MessageEvent(
1239
+ source=self.build_source(
1240
+ chat_id=group_openid,
1241
+ user_id=str(author.get("member_openid", "")),
1242
+ chat_type="group",
1243
+ ),
1244
+ text=text,
1245
+ message_type=self._detect_message_type(image_urls, image_media_types),
1246
+ raw_message=d,
1247
+ message_id=msg_id,
1248
+ media_urls=image_urls,
1249
+ media_types=image_media_types,
1250
+ timestamp=self._parse_qq_timestamp(timestamp),
1251
+ )
1252
+ await self.handle_message(event)
1253
+
1254
+ async def _handle_guild_message(
1255
+ self,
1256
+ d: Dict[str, Any],
1257
+ msg_id: str,
1258
+ content: str,
1259
+ author: Dict[str, Any],
1260
+ timestamp: str,
1261
+ ) -> None:
1262
+ """Handle a guild/channel message event."""
1263
+ channel_id = str(d.get("channel_id", ""))
1264
+ if not channel_id:
1265
+ return
1266
+
1267
+ # Apply group_policy ACL — guild channels are group-like contexts.
1268
+ # Without this check any member of any guild the bot is in could
1269
+ # bypass the configured allowlist.
1270
+ guild_id = str(d.get("guild_id", ""))
1271
+ author_id = str(author.get("id", ""))
1272
+ if not self._is_group_allowed(guild_id or channel_id, author_id):
1273
+ logger.debug(
1274
+ "[%s] Guild message blocked by ACL: channel=%s user=%s",
1275
+ self._log_tag, channel_id, author_id,
1276
+ )
1277
+ return
1278
+
1279
+ member = d.get("member") if isinstance(d.get("member"), dict) else {}
1280
+ nick = str(member.get("nick", "")) or str(author.get("username", ""))
1281
+
1282
+ text = content
1283
+ att_result = await self._process_attachments(d.get("attachments"))
1284
+ image_urls = att_result["image_urls"]
1285
+ image_media_types = att_result["image_media_types"]
1286
+ voice_transcripts = att_result["voice_transcripts"]
1287
+ attachment_info = att_result["attachment_info"]
1288
+
1289
+ if voice_transcripts:
1290
+ voice_block = "\n".join(voice_transcripts)
1291
+ text = (
1292
+ (text + "\n\n" + voice_block).strip() if text.strip() else voice_block
1293
+ )
1294
+ if attachment_info:
1295
+ text = (
1296
+ (text + "\n\n" + attachment_info).strip()
1297
+ if text.strip()
1298
+ else attachment_info
1299
+ )
1300
+
1301
+ # Merge any quoted-message context (message_type=103 → msg_elements[0]).
1302
+ quoted = await self._process_quoted_context(d)
1303
+ text = self._merge_quote_into(text, quoted["quote_block"])
1304
+ if quoted["image_urls"]:
1305
+ image_urls = image_urls + quoted["image_urls"]
1306
+ image_media_types = image_media_types + quoted["image_media_types"]
1307
+
1308
+ if not text.strip() and not image_urls:
1309
+ return
1310
+
1311
+ self._chat_type_map[channel_id] = "guild"
1312
+ event = MessageEvent(
1313
+ source=self.build_source(
1314
+ chat_id=channel_id,
1315
+ user_id=str(author.get("id", "")),
1316
+ user_name=nick or None,
1317
+ chat_type="group",
1318
+ ),
1319
+ text=text,
1320
+ message_type=self._detect_message_type(image_urls, image_media_types),
1321
+ raw_message=d,
1322
+ message_id=msg_id,
1323
+ media_urls=image_urls,
1324
+ media_types=image_media_types,
1325
+ timestamp=self._parse_qq_timestamp(timestamp),
1326
+ )
1327
+ await self.handle_message(event)
1328
+
1329
+ async def _handle_dm_message(
1330
+ self,
1331
+ d: Dict[str, Any],
1332
+ msg_id: str,
1333
+ content: str,
1334
+ author: Dict[str, Any],
1335
+ timestamp: str,
1336
+ ) -> None:
1337
+ """Handle a guild DM message event."""
1338
+ guild_id = str(d.get("guild_id", ""))
1339
+ if not guild_id:
1340
+ return
1341
+
1342
+ # Apply dm_policy ACL — guild DMs were previously unauthenticated.
1343
+ # Without this check any member of any guild the bot is in could
1344
+ # bypass the configured allowlist via direct messages.
1345
+ author_id = str(author.get("id", ""))
1346
+ if not self._is_dm_allowed(author_id):
1347
+ logger.debug(
1348
+ "[%s] Guild DM blocked by ACL: guild=%s user=%s",
1349
+ self._log_tag, guild_id, author_id,
1350
+ )
1351
+ return
1352
+
1353
+ text = content
1354
+ att_result = await self._process_attachments(d.get("attachments"))
1355
+ image_urls = att_result["image_urls"]
1356
+ image_media_types = att_result["image_media_types"]
1357
+ voice_transcripts = att_result["voice_transcripts"]
1358
+ attachment_info = att_result["attachment_info"]
1359
+
1360
+ if voice_transcripts:
1361
+ voice_block = "\n".join(voice_transcripts)
1362
+ text = (
1363
+ (text + "\n\n" + voice_block).strip() if text.strip() else voice_block
1364
+ )
1365
+ if attachment_info:
1366
+ text = (
1367
+ (text + "\n\n" + attachment_info).strip()
1368
+ if text.strip()
1369
+ else attachment_info
1370
+ )
1371
+
1372
+ # Merge any quoted-message context (message_type=103 → msg_elements[0]).
1373
+ quoted = await self._process_quoted_context(d)
1374
+ text = self._merge_quote_into(text, quoted["quote_block"])
1375
+ if quoted["image_urls"]:
1376
+ image_urls = image_urls + quoted["image_urls"]
1377
+ image_media_types = image_media_types + quoted["image_media_types"]
1378
+
1379
+ if not text.strip() and not image_urls:
1380
+ return
1381
+
1382
+ self._chat_type_map[guild_id] = "dm"
1383
+ event = MessageEvent(
1384
+ source=self.build_source(
1385
+ chat_id=guild_id,
1386
+ user_id=str(author.get("id", "")),
1387
+ chat_type="dm",
1388
+ ),
1389
+ text=text,
1390
+ message_type=self._detect_message_type(image_urls, image_media_types),
1391
+ raw_message=d,
1392
+ message_id=msg_id,
1393
+ media_urls=image_urls,
1394
+ media_types=image_media_types,
1395
+ timestamp=self._parse_qq_timestamp(timestamp),
1396
+ )
1397
+ await self.handle_message(event)
1398
+
1399
+ # ------------------------------------------------------------------
1400
+ # Quoted-message handling
1401
+ # ------------------------------------------------------------------
1402
+
1403
+ async def _process_quoted_context(
1404
+ self,
1405
+ d: Dict[str, Any],
1406
+ ) -> Dict[str, Any]:
1407
+ """Process the quoted message a user is replying to.
1408
+
1409
+ When a user replies while quoting another message, the platform sets
1410
+ ``message_type = 103`` and pushes the referenced message's content and
1411
+ attachments inside ``msg_elements[0]``. The old adapter ignored
1412
+ ``msg_elements`` entirely, so:
1413
+
1414
+ - Quoted text was surfaced only when the user typed something of
1415
+ their own — bare quote-replies showed nothing.
1416
+ - Quoted attachments (images, voice, files) were never downloaded
1417
+ or described.
1418
+ - Quoted voice messages specifically produced no transcript, so the
1419
+ LLM had no way to see what the user was referring to.
1420
+
1421
+ This method parses ``msg_elements`` and runs the quoted attachments
1422
+ through the same :meth:`_process_attachments` pipeline as the main
1423
+ message body, so quoted voice messages get STT transcripts and
1424
+ quoted images are cached identically.
1425
+
1426
+ :param d: Raw inbound message dict (from the WS dispatch payload).
1427
+ :returns: Dict with keys:
1428
+
1429
+ - ``quote_block``: string to prepend to the user's text body
1430
+ (empty when there's nothing quoted).
1431
+ - ``image_urls``: list of cached quoted-image paths.
1432
+ - ``image_media_types``: parallel list of image MIME types.
1433
+ """
1434
+ empty = {
1435
+ "quote_block": "",
1436
+ "image_urls": [],
1437
+ "image_media_types": [],
1438
+ }
1439
+ # Short-circuit: only message_type 103 indicates a quote.
1440
+ try:
1441
+ if int(d.get("message_type", 0) or 0) != 103:
1442
+ return empty
1443
+ except (TypeError, ValueError):
1444
+ return empty
1445
+
1446
+ elements = d.get("msg_elements")
1447
+ if not isinstance(elements, list) or not elements:
1448
+ return empty
1449
+
1450
+ # msg_elements[0] carries the referenced message. Additional elements
1451
+ # (if any) are very rare in practice; we concatenate their text and
1452
+ # union their attachments for completeness.
1453
+ quoted_text_parts: List[str] = []
1454
+ all_attachments: List[Dict[str, Any]] = []
1455
+ for elem in elements:
1456
+ if not isinstance(elem, dict):
1457
+ continue
1458
+ etext = str(elem.get("content", "")).strip()
1459
+ if etext:
1460
+ quoted_text_parts.append(etext)
1461
+ eatts = elem.get("attachments")
1462
+ if isinstance(eatts, list):
1463
+ for a in eatts:
1464
+ if isinstance(a, dict):
1465
+ all_attachments.append(a)
1466
+
1467
+ att_result = await self._process_attachments(all_attachments)
1468
+ quoted_voice = att_result.get("voice_transcripts") or []
1469
+ quoted_info = att_result.get("attachment_info") or ""
1470
+ quoted_images = att_result.get("image_urls") or []
1471
+ quoted_image_types = att_result.get("image_media_types") or []
1472
+
1473
+ lines: List[str] = []
1474
+ if quoted_text_parts:
1475
+ lines.append(" ".join(quoted_text_parts))
1476
+ for t in quoted_voice:
1477
+ lines.append(t)
1478
+ if quoted_info:
1479
+ lines.append(quoted_info)
1480
+
1481
+ if not lines and not quoted_images:
1482
+ return empty
1483
+
1484
+ if lines:
1485
+ quote_block = "[Quoted message]:\n" + "\n".join(lines)
1486
+ else:
1487
+ # Images-only quote: give the LLM at least a marker so it knows
1488
+ # context was referenced.
1489
+ quote_block = "[Quoted message]: (image)"
1490
+
1491
+ return {
1492
+ "quote_block": quote_block,
1493
+ "image_urls": quoted_images,
1494
+ "image_media_types": quoted_image_types,
1495
+ }
1496
+
1497
+ @staticmethod
1498
+ def _merge_quote_into(text: str, quote_block: str) -> str:
1499
+ """Prepend ``quote_block`` to *text*, separated by a blank line."""
1500
+ if not quote_block:
1501
+ return text
1502
+ if text.strip():
1503
+ return f"{quote_block}\n\n{text}".strip()
1504
+ return quote_block
1505
+
1506
+ # ------------------------------------------------------------------
1507
+ # Attachment processing
1508
+ # ------------------------------------------------------------------
1509
+
1510
+ @staticmethod
1511
+ def _detect_message_type(media_urls: list, media_types: list):
1512
+ """Determine MessageType from attachment content types."""
1513
+ if not media_urls:
1514
+ return MessageType.TEXT
1515
+ if not media_types:
1516
+ return MessageType.PHOTO
1517
+ first_type = media_types[0].lower() if media_types else ""
1518
+ if "audio" in first_type or "voice" in first_type or "silk" in first_type:
1519
+ return MessageType.VOICE
1520
+ if "video" in first_type:
1521
+ return MessageType.VIDEO
1522
+ if "image" in first_type or "photo" in first_type:
1523
+ return MessageType.PHOTO
1524
+ logger.debug(
1525
+ "Unknown media content_type '%s', defaulting to TEXT",
1526
+ first_type,
1527
+ )
1528
+ return MessageType.TEXT
1529
+
1530
+ async def _process_attachments(
1531
+ self,
1532
+ attachments: Any,
1533
+ ) -> Dict[str, Any]:
1534
+ """Process inbound attachments (all message types).
1535
+
1536
+ Mirrors OpenClaw's ``processAttachments`` — handles images, voice, and
1537
+ other files uniformly.
1538
+
1539
+ Returns a dict with:
1540
+ - image_urls: list[str] — cached local image paths
1541
+ - image_media_types: list[str] — MIME types of cached images
1542
+ - voice_transcripts: list[str] — STT transcripts for voice messages
1543
+ - attachment_info: str — text description of non-image, non-voice attachments
1544
+ """
1545
+ if not isinstance(attachments, list):
1546
+ return {
1547
+ "image_urls": [],
1548
+ "image_media_types": [],
1549
+ "voice_transcripts": [],
1550
+ "attachment_info": "",
1551
+ }
1552
+
1553
+ image_urls: List[str] = []
1554
+ image_media_types: List[str] = []
1555
+ voice_transcripts: List[str] = []
1556
+ other_attachments: List[str] = []
1557
+
1558
+ for att in attachments:
1559
+ if not isinstance(att, dict):
1560
+ continue
1561
+
1562
+ ct = str(att.get("content_type", "")).strip().lower()
1563
+ url_raw = str(att.get("url", "")).strip()
1564
+ filename = str(att.get("filename", ""))
1565
+ if url_raw.startswith("//"):
1566
+ url = f"https:{url_raw}"
1567
+ elif url_raw:
1568
+ url = url_raw
1569
+ else:
1570
+ url = ""
1571
+ continue
1572
+
1573
+ logger.debug(
1574
+ "[%s] Processing attachment: content_type=%s, url=%s, filename=%s",
1575
+ self._log_tag,
1576
+ ct,
1577
+ url[:80],
1578
+ filename,
1579
+ )
1580
+
1581
+ if self._is_voice_content_type(ct, filename):
1582
+ # Voice: use QQ's asr_refer_text first, then voice_wav_url, then STT.
1583
+ asr_refer = (
1584
+ str(att.get("asr_refer_text", "")).strip()
1585
+ if isinstance(att.get("asr_refer_text"), str)
1586
+ else ""
1587
+ )
1588
+ voice_wav_url = (
1589
+ str(att.get("voice_wav_url", "")).strip()
1590
+ if isinstance(att.get("voice_wav_url"), str)
1591
+ else ""
1592
+ )
1593
+
1594
+ transcript = await self._stt_voice_attachment(
1595
+ url,
1596
+ ct,
1597
+ filename,
1598
+ asr_refer_text=asr_refer or None,
1599
+ voice_wav_url=voice_wav_url or None,
1600
+ )
1601
+ if transcript:
1602
+ voice_transcripts.append(f"[Voice] {transcript}")
1603
+ logger.debug("[%s] Voice transcript: %s", self._log_tag, transcript)
1604
+ else:
1605
+ logger.warning("[%s] Voice STT failed for %s", self._log_tag, url[:60])
1606
+ voice_transcripts.append("[Voice] [语音识别失败]")
1607
+ elif ct.startswith("image/"):
1608
+ # Image: download and cache locally.
1609
+ try:
1610
+ cached_path = await self._download_and_cache(url, ct)
1611
+ if cached_path and os.path.isfile(cached_path):
1612
+ image_urls.append(cached_path)
1613
+ image_media_types.append(ct or "image/jpeg")
1614
+ elif cached_path:
1615
+ logger.warning(
1616
+ "[%s] Cached image path does not exist: %s",
1617
+ self._log_tag,
1618
+ cached_path,
1619
+ )
1620
+ except Exception as exc:
1621
+ logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc)
1622
+ else:
1623
+ # Other attachments (video, file, etc.): record as text.
1624
+ try:
1625
+ cached_path = await self._download_and_cache(url, ct)
1626
+ if cached_path:
1627
+ other_attachments.append(f"[Attachment: {filename or ct}]")
1628
+ except Exception as exc:
1629
+ logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc)
1630
+
1631
+ attachment_info = "\n".join(other_attachments) if other_attachments else ""
1632
+ return {
1633
+ "image_urls": image_urls,
1634
+ "image_media_types": image_media_types,
1635
+ "voice_transcripts": voice_transcripts,
1636
+ "attachment_info": attachment_info,
1637
+ }
1638
+
1639
+ async def _download_and_cache(self, url: str, content_type: str) -> Optional[str]:
1640
+ """Download a URL and cache it locally."""
1641
+ from tools.url_safety import is_safe_url
1642
+
1643
+ if not is_safe_url(url):
1644
+ raise ValueError(f"Blocked unsafe URL: {url[:80]}")
1645
+
1646
+ if not self._http_client:
1647
+ return None
1648
+
1649
+ try:
1650
+ resp = await self._http_client.get(
1651
+ url,
1652
+ timeout=30.0,
1653
+ headers=self._qq_media_headers(),
1654
+ )
1655
+ resp.raise_for_status()
1656
+ data = resp.content
1657
+ except Exception as exc:
1658
+ logger.debug(
1659
+ "[%s] Download failed for %s: %s", self._log_tag, url[:80], exc
1660
+ )
1661
+ return None
1662
+
1663
+ if content_type.startswith("image/"):
1664
+ ext = mimetypes.guess_extension(content_type) or ".jpg"
1665
+ return cache_image_from_bytes(data, ext)
1666
+ elif content_type == "voice" or content_type.startswith("audio/"):
1667
+ # QQ voice messages are typically .amr or .silk format.
1668
+ # Convert to .wav using ffmpeg so STT engines can process it.
1669
+ return await self._convert_audio_to_wav(data, url)
1670
+ else:
1671
+ filename = Path(urlparse(url).path).name or "qq_attachment"
1672
+ return cache_document_from_bytes(data, filename)
1673
+
1674
+ @staticmethod
1675
+ def _is_voice_content_type(content_type: str, filename: str) -> bool:
1676
+ """Check if an attachment is a voice/audio message."""
1677
+ ct = content_type.strip().lower()
1678
+ fn = filename.strip().lower()
1679
+ if ct == "voice" or ct.startswith("audio/"):
1680
+ return True
1681
+ _VOICE_EXTENSIONS = (
1682
+ ".silk",
1683
+ ".amr",
1684
+ ".mp3",
1685
+ ".wav",
1686
+ ".ogg",
1687
+ ".m4a",
1688
+ ".aac",
1689
+ ".speex",
1690
+ ".flac",
1691
+ )
1692
+ if any(fn.endswith(ext) for ext in _VOICE_EXTENSIONS):
1693
+ return True
1694
+ return False
1695
+
1696
+ def _qq_media_headers(self) -> Dict[str, str]:
1697
+ """Return Authorization headers for QQ multimedia CDN downloads.
1698
+
1699
+ QQ's multimedia URLs (multimedia.nt.qq.com.cn) require the bot's
1700
+ access token in an Authorization header, otherwise the download
1701
+ returns a non-200 status.
1702
+ """
1703
+ if self._access_token:
1704
+ return {"Authorization": f"QQBot {self._access_token}"}
1705
+ return {}
1706
+
1707
+ async def _stt_voice_attachment(
1708
+ self,
1709
+ url: str,
1710
+ content_type: str,
1711
+ filename: str,
1712
+ *,
1713
+ asr_refer_text: Optional[str] = None,
1714
+ voice_wav_url: Optional[str] = None,
1715
+ ) -> Optional[str]:
1716
+ """Download a voice attachment, convert to wav, and transcribe.
1717
+
1718
+ Priority:
1719
+ 1. QQ's built-in ``asr_refer_text`` (Tencent's own ASR — free, no API call).
1720
+ 2. Self-hosted STT on ``voice_wav_url`` (pre-converted WAV from QQ, avoids SILK decoding).
1721
+ 3. Self-hosted STT on the original attachment URL (requires SILK→WAV conversion).
1722
+
1723
+ Returns the transcript text, or None on failure.
1724
+ """
1725
+ # 1. Use QQ's built-in ASR text if available
1726
+ if asr_refer_text:
1727
+ logger.debug(
1728
+ "[%s] STT: using QQ asr_refer_text: %r", self._log_tag, asr_refer_text[:100]
1729
+ )
1730
+ return asr_refer_text
1731
+
1732
+ # Determine which URL to download (prefer voice_wav_url — already WAV)
1733
+ download_url = url
1734
+ is_pre_wav = False
1735
+ if voice_wav_url:
1736
+ if voice_wav_url.startswith("//"):
1737
+ voice_wav_url = f"https:{voice_wav_url}"
1738
+ download_url = voice_wav_url
1739
+ is_pre_wav = True
1740
+ logger.debug("[%s] STT: using voice_wav_url (pre-converted WAV)", self._log_tag)
1741
+
1742
+ from tools.url_safety import is_safe_url
1743
+ if not is_safe_url(download_url):
1744
+ logger.warning("[QQ] STT blocked unsafe URL: %s", download_url[:80])
1745
+ return None
1746
+
1747
+ try:
1748
+ # 2. Download audio (QQ CDN requires Authorization header)
1749
+ if not self._http_client:
1750
+ logger.warning("[%s] STT: no HTTP client", self._log_tag)
1751
+ return None
1752
+
1753
+ download_headers = self._qq_media_headers()
1754
+ logger.debug(
1755
+ "[%s] STT: downloading voice from %s (pre_wav=%s, headers=%s)",
1756
+ self._log_tag,
1757
+ download_url[:80],
1758
+ is_pre_wav,
1759
+ bool(download_headers),
1760
+ )
1761
+ resp = await self._http_client.get(
1762
+ download_url,
1763
+ timeout=30.0,
1764
+ headers=download_headers,
1765
+ follow_redirects=True,
1766
+ )
1767
+ resp.raise_for_status()
1768
+ audio_data = resp.content
1769
+ logger.debug(
1770
+ "[%s] STT: downloaded %d bytes, content_type=%s",
1771
+ self._log_tag,
1772
+ len(audio_data),
1773
+ resp.headers.get("content-type", "unknown"),
1774
+ )
1775
+
1776
+ if len(audio_data) < 10:
1777
+ logger.warning(
1778
+ "[%s] STT: downloaded data too small (%d bytes), skipping",
1779
+ self._log_tag,
1780
+ len(audio_data),
1781
+ )
1782
+ return None
1783
+
1784
+ # 3. Convert to wav (skip if we already have a pre-converted WAV)
1785
+ if is_pre_wav:
1786
+ import tempfile
1787
+
1788
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
1789
+ tmp.write(audio_data)
1790
+ wav_path = tmp.name
1791
+ logger.debug(
1792
+ "[%s] STT: using pre-converted WAV directly (%d bytes)",
1793
+ self._log_tag,
1794
+ len(audio_data),
1795
+ )
1796
+ else:
1797
+ logger.debug(
1798
+ "[%s] STT: converting to wav, filename=%r", self._log_tag, filename
1799
+ )
1800
+ wav_path = await self._convert_audio_to_wav_file(audio_data, filename)
1801
+ if not wav_path or not Path(wav_path).exists():
1802
+ logger.warning(
1803
+ "[%s] STT: ffmpeg conversion produced no output", self._log_tag
1804
+ )
1805
+ return None
1806
+
1807
+ # 4. Call STT API
1808
+ logger.debug("[%s] STT: calling ASR on %s", self._log_tag, wav_path)
1809
+ transcript = await self._call_stt(wav_path)
1810
+
1811
+ # 5. Cleanup temp file
1812
+ try:
1813
+ os.unlink(wav_path)
1814
+ except OSError:
1815
+ pass
1816
+
1817
+ if transcript:
1818
+ logger.debug("[%s] STT success: %r", self._log_tag, transcript[:100])
1819
+ else:
1820
+ logger.warning("[%s] STT: ASR returned empty transcript", self._log_tag)
1821
+ return transcript
1822
+ except (httpx.HTTPStatusError, httpx.TransportError, IOError) as exc:
1823
+ logger.warning(
1824
+ "[%s] STT failed for voice attachment: %s: %s",
1825
+ self._log_tag,
1826
+ type(exc).__name__,
1827
+ exc,
1828
+ )
1829
+ return None
1830
+
1831
+ async def _convert_audio_to_wav_file(
1832
+ self, audio_data: bytes, filename: str
1833
+ ) -> Optional[str]:
1834
+ """Convert audio bytes to a temp .wav file using pilk (SILK) or ffmpeg.
1835
+
1836
+ QQ voice messages are typically SILK format which ffmpeg cannot decode.
1837
+ Strategy: always try pilk first, fall back to ffmpeg if pilk fails.
1838
+
1839
+ Returns the wav file path, or None on failure.
1840
+ """
1841
+ import tempfile
1842
+
1843
+ ext = (
1844
+ Path(filename).suffix.lower()
1845
+ if Path(filename).suffix
1846
+ else self._guess_ext_from_data(audio_data)
1847
+ )
1848
+ logger.info(
1849
+ "[%s] STT: audio_data size=%d, ext=%r, first_20_bytes=%r",
1850
+ self._log_tag,
1851
+ len(audio_data),
1852
+ ext,
1853
+ audio_data[:20],
1854
+ )
1855
+
1856
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_src:
1857
+ tmp_src.write(audio_data)
1858
+ src_path = tmp_src.name
1859
+
1860
+ wav_path = src_path.rsplit(".", 1)[0] + ".wav"
1861
+
1862
+ # Try pilk first (handles SILK and many other formats)
1863
+ result = await self._convert_silk_to_wav(src_path, wav_path)
1864
+
1865
+ # If pilk failed, try ffmpeg
1866
+ if not result:
1867
+ result = await self._convert_ffmpeg_to_wav(src_path, wav_path)
1868
+
1869
+ # If ffmpeg also failed, try writing raw PCM as WAV (last resort)
1870
+ if not result:
1871
+ result = await self._convert_raw_to_wav(audio_data, wav_path)
1872
+
1873
+ # Cleanup source file
1874
+ try:
1875
+ os.unlink(src_path)
1876
+ except OSError:
1877
+ pass
1878
+
1879
+ return result
1880
+
1881
+ @staticmethod
1882
+ def _guess_ext_from_data(data: bytes) -> str:
1883
+ """Guess file extension from magic bytes."""
1884
+ if data[:9] == b"#!SILK_V3" or data[:5] == b"#!SILK":
1885
+ return ".silk"
1886
+ if data[:2] == b"\x02!":
1887
+ return ".silk"
1888
+ if data[:4] == b"RIFF":
1889
+ return ".wav"
1890
+ if data[:4] == b"fLaC":
1891
+ return ".flac"
1892
+ if data[:2] in {b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"}:
1893
+ return ".mp3"
1894
+ if data[:4] == b"\x30\x26\xb2\x75" or data[:4] == b"\x4f\x67\x67\x53":
1895
+ return ".ogg"
1896
+ if data[:4] == b"\x00\x00\x00\x20" or data[:4] == b"\x00\x00\x00\x1c":
1897
+ return ".amr"
1898
+ # Default to .amr for unknown (QQ's most common voice format)
1899
+ return ".amr"
1900
+
1901
+ @staticmethod
1902
+ def _looks_like_silk(data: bytes) -> bool:
1903
+ """Check if bytes look like a SILK audio file."""
1904
+ return data[:4] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
1905
+
1906
+ async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]:
1907
+ """Convert audio file to WAV using the pilk library.
1908
+
1909
+ Tries the file as-is first, then as .silk if the extension differs.
1910
+ pilk can handle SILK files with various headers (or no header).
1911
+ """
1912
+ try:
1913
+ import pilk
1914
+ except ImportError:
1915
+ logger.warning(
1916
+ "[%s] pilk not installed — cannot decode SILK audio. Run: pip install pilk",
1917
+ self._log_tag,
1918
+ )
1919
+ return None
1920
+
1921
+ # Try converting the file as-is
1922
+ try:
1923
+ pilk.silk_to_wav(src_path, wav_path, rate=16000)
1924
+ if Path(wav_path).exists() and Path(wav_path).stat().st_size > 44:
1925
+ logger.debug(
1926
+ "[%s] pilk converted %s to wav (%d bytes)",
1927
+ self._log_tag,
1928
+ Path(src_path).name,
1929
+ Path(wav_path).stat().st_size,
1930
+ )
1931
+ return wav_path
1932
+ except Exception as exc:
1933
+ logger.debug("[%s] pilk direct conversion failed: %s", self._log_tag, exc)
1934
+
1935
+ # Try renaming to .silk and converting (pilk checks the extension)
1936
+ silk_path = src_path.rsplit(".", 1)[0] + ".silk"
1937
+ try:
1938
+ import shutil
1939
+
1940
+ shutil.copy2(src_path, silk_path)
1941
+ pilk.silk_to_wav(silk_path, wav_path, rate=16000)
1942
+ if Path(wav_path).exists() and Path(wav_path).stat().st_size > 44:
1943
+ logger.debug(
1944
+ "[%s] pilk converted %s (as .silk) to wav (%d bytes)",
1945
+ self._log_tag,
1946
+ Path(src_path).name,
1947
+ Path(wav_path).stat().st_size,
1948
+ )
1949
+ return wav_path
1950
+ except Exception as exc:
1951
+ logger.debug("[%s] pilk .silk conversion failed: %s", self._log_tag, exc)
1952
+ finally:
1953
+ try:
1954
+ os.unlink(silk_path)
1955
+ except OSError:
1956
+ pass
1957
+
1958
+ return None
1959
+
1960
+ async def _convert_raw_to_wav(self, audio_data: bytes, wav_path: str) -> Optional[str]:
1961
+ """Last resort: try writing audio data as raw PCM 16-bit mono 16kHz WAV.
1962
+
1963
+ This will produce garbage if the data isn't raw PCM, but at least
1964
+ the ASR engine won't crash — it'll just return empty.
1965
+ """
1966
+ try:
1967
+ import wave
1968
+
1969
+ with wave.open(wav_path, "w") as wf:
1970
+ wf.setnchannels(1)
1971
+ wf.setsampwidth(2)
1972
+ wf.setframerate(16000)
1973
+ wf.writeframes(audio_data)
1974
+ return wav_path
1975
+ except Exception as exc:
1976
+ logger.debug("[%s] raw PCM fallback failed: %s", self._log_tag, exc)
1977
+ return None
1978
+
1979
+ async def _convert_ffmpeg_to_wav(self, src_path: str, wav_path: str) -> Optional[str]:
1980
+ """Convert audio file to WAV using ffmpeg."""
1981
+ try:
1982
+ proc = await asyncio.create_subprocess_exec(
1983
+ "ffmpeg",
1984
+ "-y",
1985
+ "-i",
1986
+ src_path,
1987
+ "-ar",
1988
+ "16000",
1989
+ "-ac",
1990
+ "1",
1991
+ wav_path,
1992
+ stdout=asyncio.subprocess.DEVNULL,
1993
+ stderr=asyncio.subprocess.PIPE,
1994
+ )
1995
+ await asyncio.wait_for(proc.wait(), timeout=30)
1996
+ if proc.returncode != 0:
1997
+ stderr = await proc.stderr.read() if proc.stderr else b""
1998
+ logger.warning(
1999
+ "[%s] ffmpeg failed for %s: %s",
2000
+ self._log_tag,
2001
+ Path(src_path).name,
2002
+ stderr[:200].decode(errors="replace"),
2003
+ )
2004
+ return None
2005
+ except (asyncio.TimeoutError, FileNotFoundError) as exc:
2006
+ logger.warning("[%s] ffmpeg conversion error: %s", self._log_tag, exc)
2007
+ return None
2008
+
2009
+ if not Path(wav_path).exists() or Path(wav_path).stat().st_size <= 44:
2010
+ logger.warning(
2011
+ "[%s] ffmpeg produced no/small output for %s",
2012
+ self._log_tag,
2013
+ Path(src_path).name,
2014
+ )
2015
+ return None
2016
+ logger.debug(
2017
+ "[%s] ffmpeg converted %s to wav (%d bytes)",
2018
+ self._log_tag,
2019
+ Path(src_path).name,
2020
+ Path(wav_path).stat().st_size,
2021
+ )
2022
+ return wav_path
2023
+
2024
+ def _resolve_stt_config(self) -> Optional[Dict[str, str]]:
2025
+ """Resolve STT backend configuration from config/environment.
2026
+
2027
+ Priority:
2028
+ 1. Plugin-specific: ``channels.qqbot.stt`` in config.yaml → ``self.config.extra["stt"]``
2029
+ 2. QQ-specific env vars: ``QQ_STT_API_KEY`` / ``QQ_STT_BASE_URL`` / ``QQ_STT_MODEL``
2030
+ 3. Return None if nothing is configured (STT will be skipped, QQ built-in ASR still works).
2031
+ """
2032
+ extra = self.config.extra or {}
2033
+
2034
+ # 1. Plugin-specific STT config (matches OpenClaw's channels.qqbot.stt)
2035
+ stt_cfg = extra.get("stt")
2036
+ if isinstance(stt_cfg, dict) and stt_cfg.get("enabled") is not False:
2037
+ base_url = stt_cfg.get("baseUrl") or stt_cfg.get("base_url", "")
2038
+ api_key = stt_cfg.get("apiKey") or stt_cfg.get("api_key", "")
2039
+ model = stt_cfg.get("model", "")
2040
+ if base_url and api_key:
2041
+ return {
2042
+ "base_url": base_url.rstrip("/"),
2043
+ "api_key": api_key,
2044
+ "model": model or "whisper-1",
2045
+ }
2046
+ # Provider-only config: just model name, use default provider
2047
+ if api_key:
2048
+ provider = stt_cfg.get("provider", "zai")
2049
+ # Map provider to base URL
2050
+ _PROVIDER_BASE_URLS = {
2051
+ "zai": "https://open.bigmodel.cn/api/coding/paas/v4",
2052
+ "openai": "https://api.openai.com/v1",
2053
+ "glm": "https://open.bigmodel.cn/api/coding/paas/v4",
2054
+ }
2055
+ base_url = _PROVIDER_BASE_URLS.get(provider, "")
2056
+ if base_url:
2057
+ return {
2058
+ "base_url": base_url,
2059
+ "api_key": api_key,
2060
+ "model": model
2061
+ or ("glm-asr" if provider in {"zai", "glm"} else "whisper-1"),
2062
+ }
2063
+
2064
+ # 2. QQ-specific env vars (set by `hermes setup gateway` / `hermes gateway`)
2065
+ qq_stt_key = os.getenv("QQ_STT_API_KEY", "")
2066
+ if qq_stt_key:
2067
+ base_url = os.getenv(
2068
+ "QQ_STT_BASE_URL",
2069
+ "https://open.bigmodel.cn/api/coding/paas/v4",
2070
+ )
2071
+ model = os.getenv("QQ_STT_MODEL", "glm-asr")
2072
+ return {
2073
+ "base_url": base_url.rstrip("/"),
2074
+ "api_key": qq_stt_key,
2075
+ "model": model,
2076
+ }
2077
+
2078
+ return None
2079
+
2080
+ async def _call_stt(self, wav_path: str) -> Optional[str]:
2081
+ """Call an OpenAI-compatible STT API to transcribe a wav file.
2082
+
2083
+ Uses the provider configured in ``channels.qqbot.stt`` config,
2084
+ falling back to QQ's built-in ``asr_refer_text`` if not configured.
2085
+ Returns None if STT is not configured or the call fails.
2086
+ """
2087
+ stt_cfg = self._resolve_stt_config()
2088
+ if not stt_cfg:
2089
+ logger.warning(
2090
+ "[%s] STT not configured (no stt config or QQ_STT_API_KEY)",
2091
+ self._log_tag,
2092
+ )
2093
+ return None
2094
+
2095
+ base_url = stt_cfg["base_url"]
2096
+ api_key = stt_cfg["api_key"]
2097
+ model = stt_cfg["model"]
2098
+
2099
+ try:
2100
+ with open(wav_path, "rb") as f:
2101
+ resp = await self._http_client.post(
2102
+ f"{base_url}/audio/transcriptions",
2103
+ headers={"Authorization": f"Bearer {api_key}"},
2104
+ files={"file": (Path(wav_path).name, f, "audio/wav")},
2105
+ data={"model": model},
2106
+ timeout=30.0,
2107
+ )
2108
+ resp.raise_for_status()
2109
+ result = resp.json()
2110
+ # Zhipu/GLM format: {"choices": [{"message": {"content": "transcript text"}}]}
2111
+ choices = result.get("choices", [])
2112
+ if choices:
2113
+ content = choices[0].get("message", {}).get("content", "")
2114
+ if content.strip():
2115
+ return content.strip()
2116
+ # OpenAI/Whisper format: {"text": "transcript text"}
2117
+ text = result.get("text", "")
2118
+ if text.strip():
2119
+ return text.strip()
2120
+ return None
2121
+ except (httpx.HTTPStatusError, IOError) as exc:
2122
+ logger.warning(
2123
+ "[%s] STT API call failed (model=%s, base=%s): %s",
2124
+ self._log_tag,
2125
+ model,
2126
+ base_url[:50],
2127
+ exc,
2128
+ )
2129
+ return None
2130
+
2131
+ async def _convert_audio_to_wav(
2132
+ self, audio_data: bytes, source_url: str
2133
+ ) -> Optional[str]:
2134
+ """Convert audio bytes to .wav using pilk (SILK) or ffmpeg, caching the result."""
2135
+ import tempfile
2136
+
2137
+ # Determine source format from magic bytes or URL
2138
+ ext = (
2139
+ Path(urlparse(source_url).path).suffix.lower()
2140
+ if urlparse(source_url).path
2141
+ else ""
2142
+ )
2143
+ if not ext or ext not in {
2144
+ ".silk",
2145
+ ".amr",
2146
+ ".mp3",
2147
+ ".wav",
2148
+ ".ogg",
2149
+ ".m4a",
2150
+ ".aac",
2151
+ ".flac",
2152
+ }:
2153
+ ext = self._guess_ext_from_data(audio_data)
2154
+
2155
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_src:
2156
+ tmp_src.write(audio_data)
2157
+ src_path = tmp_src.name
2158
+
2159
+ wav_path = src_path.rsplit(".", 1)[0] + ".wav"
2160
+ try:
2161
+ is_silk = ext == ".silk" or self._looks_like_silk(audio_data)
2162
+ if is_silk:
2163
+ result = await self._convert_silk_to_wav(src_path, wav_path)
2164
+ else:
2165
+ result = await self._convert_ffmpeg_to_wav(src_path, wav_path)
2166
+
2167
+ if not result:
2168
+ logger.warning(
2169
+ "[%s] audio conversion failed for %s (format=%s)",
2170
+ self._log_tag,
2171
+ source_url[:60],
2172
+ ext,
2173
+ )
2174
+ return cache_document_from_bytes(audio_data, f"qq_voice{ext}")
2175
+ except Exception:
2176
+ return cache_document_from_bytes(audio_data, f"qq_voice{ext}")
2177
+ finally:
2178
+ try:
2179
+ os.unlink(src_path)
2180
+ except OSError:
2181
+ pass
2182
+
2183
+ # Verify output and cache
2184
+ try:
2185
+ wav_data = Path(wav_path).read_bytes()
2186
+ os.unlink(wav_path)
2187
+ return cache_document_from_bytes(wav_data, "qq_voice.wav")
2188
+ except Exception as exc:
2189
+ logger.debug("[%s] Failed to read converted wav: %s", self._log_tag, exc)
2190
+ return None
2191
+
2192
+ # ------------------------------------------------------------------
2193
+ # Outbound messaging — REST API
2194
+ # ------------------------------------------------------------------
2195
+
2196
+ async def _api_request(
2197
+ self,
2198
+ method: str,
2199
+ path: str,
2200
+ body: Optional[Dict[str, Any]] = None,
2201
+ timeout: float = DEFAULT_API_TIMEOUT,
2202
+ ) -> Dict[str, Any]:
2203
+ """Make an authenticated REST API request to QQ Bot API."""
2204
+ if not self._http_client:
2205
+ raise RuntimeError("HTTP client not initialized — not connected?")
2206
+
2207
+ token = await self._ensure_token()
2208
+ headers = {
2209
+ "Authorization": f"QQBot {token}",
2210
+ "Content-Type": "application/json",
2211
+ "User-Agent": build_user_agent(),
2212
+ }
2213
+
2214
+ try:
2215
+ resp = await self._http_client.request(
2216
+ method,
2217
+ f"{API_BASE}{path}",
2218
+ headers=headers,
2219
+ json=body,
2220
+ timeout=timeout,
2221
+ )
2222
+ data = resp.json()
2223
+ if resp.status_code >= 400:
2224
+ raise RuntimeError(
2225
+ f"QQ Bot API error [{resp.status_code}] {path}: "
2226
+ f"{data.get('message', data)}"
2227
+ )
2228
+ return data
2229
+ except httpx.TimeoutException as exc:
2230
+ raise RuntimeError(f"QQ Bot API timeout [{path}]: {exc}") from exc
2231
+
2232
+ async def _upload_media(
2233
+ self,
2234
+ target_type: str,
2235
+ target_id: str,
2236
+ file_type: int,
2237
+ url: Optional[str] = None,
2238
+ file_data: Optional[str] = None,
2239
+ srv_send_msg: bool = False,
2240
+ file_name: Optional[str] = None,
2241
+ ) -> Dict[str, Any]:
2242
+ """Upload media and return file_info."""
2243
+ path = (
2244
+ f"/v2/users/{target_id}/files"
2245
+ if target_type == "c2c"
2246
+ else f"/v2/groups/{target_id}/files"
2247
+ )
2248
+
2249
+ body: Dict[str, Any] = {
2250
+ "file_type": file_type,
2251
+ "srv_send_msg": srv_send_msg,
2252
+ }
2253
+ if url:
2254
+ body["url"] = url
2255
+ elif file_data:
2256
+ body["file_data"] = file_data
2257
+ if file_type == MEDIA_TYPE_FILE and file_name:
2258
+ body["file_name"] = file_name
2259
+
2260
+ # Retry transient upload failures
2261
+ for attempt in range(3):
2262
+ try:
2263
+ return await self._api_request(
2264
+ "POST", path, body, timeout=FILE_UPLOAD_TIMEOUT
2265
+ )
2266
+ except RuntimeError as exc:
2267
+ err_msg = str(exc)
2268
+ if any(
2269
+ kw in err_msg
2270
+ for kw in ("400", "401", "Invalid", "timeout", "Timeout")
2271
+ ):
2272
+ raise
2273
+ if attempt < 2:
2274
+ await asyncio.sleep(1.5 * (attempt + 1))
2275
+ else:
2276
+ raise
2277
+
2278
+ # Maximum time (seconds) to wait for reconnection before giving up on send.
2279
+ _RECONNECT_WAIT_SECONDS = 15.0
2280
+ # How often (seconds) to poll is_connected while waiting.
2281
+ _RECONNECT_POLL_INTERVAL = 0.5
2282
+
2283
+ async def _wait_for_reconnection(self) -> bool:
2284
+ """Wait for the WebSocket listener to reconnect.
2285
+
2286
+ The listener loop (_listen_loop) auto-reconnects on disconnect, but
2287
+ there is a race window where send() is called right after a disconnect
2288
+ and before the reconnect completes. This method polls is_connected
2289
+ for up to _RECONNECT_WAIT_SECONDS.
2290
+
2291
+ Returns True if reconnected, False if still disconnected.
2292
+ """
2293
+ logger.info("[%s] Not connected — waiting for reconnection (up to %.0fs)",
2294
+ self._log_tag, self._RECONNECT_WAIT_SECONDS)
2295
+ waited = 0.0
2296
+ while waited < self._RECONNECT_WAIT_SECONDS:
2297
+ await asyncio.sleep(self._RECONNECT_POLL_INTERVAL)
2298
+ waited += self._RECONNECT_POLL_INTERVAL
2299
+ if self.is_connected:
2300
+ logger.info("[%s] Reconnected after %.1fs", self._log_tag, waited)
2301
+ return True
2302
+ logger.warning("[%s] Still not connected after %.0fs", self._log_tag, self._RECONNECT_WAIT_SECONDS)
2303
+ return False
2304
+
2305
+ async def send(
2306
+ self,
2307
+ chat_id: str,
2308
+ content: str,
2309
+ reply_to: Optional[str] = None,
2310
+ metadata: Optional[Dict[str, Any]] = None,
2311
+ ) -> SendResult:
2312
+ """Send a text or markdown message to a QQ user or group.
2313
+
2314
+ Applies format_message(), splits long messages via truncate_message(),
2315
+ and retries transient failures with exponential backoff.
2316
+ """
2317
+ del metadata
2318
+
2319
+ if not self.is_connected:
2320
+ if not await self._wait_for_reconnection():
2321
+ return SendResult(success=False, error="Not connected", retryable=True)
2322
+
2323
+ if not content or not content.strip():
2324
+ return SendResult(success=True)
2325
+
2326
+ formatted = self.format_message(content)
2327
+ chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
2328
+
2329
+ last_result = SendResult(success=False, error="No chunks")
2330
+ for chunk in chunks:
2331
+ last_result = await self._send_chunk(chat_id, chunk, reply_to)
2332
+ if not last_result.success:
2333
+ return last_result
2334
+ # Only reply_to the first chunk
2335
+ reply_to = None
2336
+ return last_result
2337
+
2338
+ async def _send_chunk(
2339
+ self,
2340
+ chat_id: str,
2341
+ content: str,
2342
+ reply_to: Optional[str] = None,
2343
+ ) -> SendResult:
2344
+ """Send a single chunk with retry + exponential backoff."""
2345
+ last_exc: Optional[Exception] = None
2346
+ chat_type = self._guess_chat_type(chat_id)
2347
+
2348
+ for attempt in range(3):
2349
+ try:
2350
+ if chat_type == "c2c":
2351
+ return await self._send_c2c_text(chat_id, content, reply_to)
2352
+ elif chat_type == "group":
2353
+ return await self._send_group_text(chat_id, content, reply_to)
2354
+ elif chat_type == "guild":
2355
+ return await self._send_guild_text(chat_id, content, reply_to)
2356
+ else:
2357
+ return SendResult(
2358
+ success=False, error=f"Unknown chat type for {chat_id}"
2359
+ )
2360
+ except Exception as exc:
2361
+ last_exc = exc
2362
+ err = str(exc).lower()
2363
+ # Permanent errors — don't retry
2364
+ if any(
2365
+ k in err
2366
+ for k in ("invalid", "forbidden", "not found", "bad request")
2367
+ ):
2368
+ break
2369
+ # Transient — back off and retry
2370
+ if attempt < 2:
2371
+ delay = 1.0 * (2 ** attempt)
2372
+ logger.warning(
2373
+ "[%s] send retry %d/3 after %.1fs: %s",
2374
+ self._log_tag,
2375
+ attempt + 1,
2376
+ delay,
2377
+ exc,
2378
+ )
2379
+ await asyncio.sleep(delay)
2380
+
2381
+ error_msg = str(last_exc) if last_exc else "Unknown error"
2382
+ logger.error("[%s] Send failed: %s", self._log_tag, error_msg)
2383
+ retryable = not any(
2384
+ k in error_msg.lower() for k in ("invalid", "forbidden", "not found")
2385
+ )
2386
+ return SendResult(success=False, error=error_msg, retryable=retryable)
2387
+
2388
+ async def _send_c2c_text(
2389
+ self,
2390
+ openid: str,
2391
+ content: str,
2392
+ reply_to: Optional[str] = None,
2393
+ keyboard: Optional[InlineKeyboard] = None,
2394
+ ) -> SendResult:
2395
+ """Send text to a C2C user via REST API.
2396
+
2397
+ :param keyboard: Optional inline keyboard attached to the message.
2398
+ """
2399
+ self._next_msg_seq(reply_to or openid)
2400
+ body = self._build_text_body(content, reply_to)
2401
+ if reply_to:
2402
+ body["msg_id"] = reply_to
2403
+ if keyboard is not None:
2404
+ body["keyboard"] = keyboard.to_dict()
2405
+
2406
+ data = await self._api_request("POST", f"/v2/users/{openid}/messages", body)
2407
+ msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
2408
+ return SendResult(success=True, message_id=msg_id, raw_response=data)
2409
+
2410
+ async def _send_group_text(
2411
+ self,
2412
+ group_openid: str,
2413
+ content: str,
2414
+ reply_to: Optional[str] = None,
2415
+ keyboard: Optional[InlineKeyboard] = None,
2416
+ ) -> SendResult:
2417
+ """Send text to a group via REST API.
2418
+
2419
+ :param keyboard: Optional inline keyboard attached to the message.
2420
+ """
2421
+ self._next_msg_seq(reply_to or group_openid)
2422
+ body = self._build_text_body(content, reply_to)
2423
+ if reply_to:
2424
+ body["msg_id"] = reply_to
2425
+ if keyboard is not None:
2426
+ body["keyboard"] = keyboard.to_dict()
2427
+
2428
+ data = await self._api_request(
2429
+ "POST", f"/v2/groups/{group_openid}/messages", body
2430
+ )
2431
+ msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
2432
+ return SendResult(success=True, message_id=msg_id, raw_response=data)
2433
+
2434
+ async def _send_guild_text(
2435
+ self, channel_id: str, content: str, reply_to: Optional[str] = None
2436
+ ) -> SendResult:
2437
+ """Send text to a guild channel via REST API."""
2438
+ body: Dict[str, Any] = {"content": content[: self.MAX_MESSAGE_LENGTH]}
2439
+ if reply_to:
2440
+ body["msg_id"] = reply_to
2441
+
2442
+ data = await self._api_request("POST", f"/channels/{channel_id}/messages", body)
2443
+ msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
2444
+ return SendResult(success=True, message_id=msg_id, raw_response=data)
2445
+
2446
+ # ------------------------------------------------------------------
2447
+ # Inline-keyboard outbound helpers (approval / update-prompt flows)
2448
+ # ------------------------------------------------------------------
2449
+
2450
+ async def send_with_keyboard(
2451
+ self,
2452
+ chat_id: str,
2453
+ content: str,
2454
+ keyboard: InlineKeyboard,
2455
+ reply_to: Optional[str] = None,
2456
+ ) -> SendResult:
2457
+ """Send a single text message with an inline keyboard attached.
2458
+
2459
+ Unlike :meth:`send`, this does NOT split long content into chunks —
2460
+ a keyboard message has exactly one interactive surface, and splitting
2461
+ would orphan the buttons from the first chunk. Callers should keep
2462
+ approval/update-prompt bodies short.
2463
+
2464
+ Guild (channel) chats don't support inline keyboards; returns a
2465
+ non-retryable failure for those.
2466
+ """
2467
+ if not self.is_connected:
2468
+ if not await self._wait_for_reconnection():
2469
+ return SendResult(
2470
+ success=False, error="Not connected", retryable=True
2471
+ )
2472
+
2473
+ chat_type = self._guess_chat_type(chat_id)
2474
+ formatted = self.format_message(content)
2475
+ truncated = formatted[: self.MAX_MESSAGE_LENGTH]
2476
+ try:
2477
+ if chat_type == "c2c":
2478
+ return await self._send_c2c_text(
2479
+ chat_id, truncated, reply_to, keyboard=keyboard,
2480
+ )
2481
+ if chat_type == "group":
2482
+ return await self._send_group_text(
2483
+ chat_id, truncated, reply_to, keyboard=keyboard,
2484
+ )
2485
+ return SendResult(
2486
+ success=False,
2487
+ error=(
2488
+ f"Inline keyboards not supported for chat_type "
2489
+ f"{chat_type!r}"
2490
+ ),
2491
+ retryable=False,
2492
+ )
2493
+ except Exception as exc:
2494
+ logger.error(
2495
+ "[%s] send_with_keyboard failed: %s", self._log_tag, exc
2496
+ )
2497
+ return SendResult(success=False, error=str(exc))
2498
+
2499
+ async def send_approval_request(
2500
+ self,
2501
+ chat_id: str,
2502
+ req: ApprovalRequest,
2503
+ reply_to: Optional[str] = None,
2504
+ ) -> SendResult:
2505
+ """Send a 3-button approval request (``allow-once / allow-always / deny``).
2506
+
2507
+ The rendered text comes from :func:`build_approval_text`; callers can
2508
+ override by passing a custom :class:`ApprovalRequest`.
2509
+
2510
+ Users click the button → ``INTERACTION_CREATE`` fires → the adapter's
2511
+ registered :meth:`set_interaction_callback` handler decodes
2512
+ ``button_data`` via :func:`parse_approval_button_data`.
2513
+ """
2514
+ from gateway.platforms.qqbot.keyboards import build_approval_text
2515
+ return await self.send_with_keyboard(
2516
+ chat_id,
2517
+ build_approval_text(req),
2518
+ build_approval_keyboard(req.session_key),
2519
+ reply_to=reply_to,
2520
+ )
2521
+
2522
+ # ------------------------------------------------------------------
2523
+ # Cross-adapter gateway contract — send_exec_approval + send_update_prompt
2524
+ # ------------------------------------------------------------------
2525
+ #
2526
+ # These mirror the signatures that gateway/run.py detects on the adapter
2527
+ # class (e.g. type(adapter).send_exec_approval, type(adapter).send_update_prompt)
2528
+ # for button-based approval / update-confirm UX. Discord, Telegram, Slack,
2529
+ # Matrix, and Feishu already implement the same contract.
2530
+
2531
+ async def send_exec_approval(
2532
+ self,
2533
+ chat_id: str,
2534
+ command: str,
2535
+ session_key: str,
2536
+ description: str = "dangerous command",
2537
+ metadata: Optional[Dict[str, Any]] = None,
2538
+ ) -> SendResult:
2539
+ """Send a button-based exec-approval prompt for a dangerous command.
2540
+
2541
+ Called by ``gateway/run.py``'s ``_approval_notify_sync`` when the
2542
+ agent is blocked waiting for approval. Button clicks resolve via
2543
+ :func:`tools.approval.resolve_gateway_approval` — dispatched by the
2544
+ adapter's interaction callback (:meth:`_default_interaction_dispatch`).
2545
+ """
2546
+ del metadata # QQ doesn't have thread_id / DM targeting overrides.
2547
+
2548
+ # Use the reply-to message for passive-message context when we have one.
2549
+ # QQ requires a msg_id on outbound messages to a user we've never
2550
+ # seen; the last inbound msg_id is the natural choice.
2551
+ msg_id = self._last_msg_id.get(chat_id)
2552
+
2553
+ req = ApprovalRequest(
2554
+ session_key=session_key,
2555
+ title=f"Execute this command?",
2556
+ description=description,
2557
+ command_preview=command,
2558
+ timeout_sec=self._APPROVAL_TIMEOUT_SECONDS,
2559
+ )
2560
+ return await self.send_approval_request(
2561
+ chat_id, req, reply_to=msg_id,
2562
+ )
2563
+
2564
+ _APPROVAL_TIMEOUT_SECONDS = 300 # matches gateway's default gateway_timeout
2565
+
2566
+ async def send_update_prompt(
2567
+ self,
2568
+ chat_id: str,
2569
+ prompt: str,
2570
+ default: str = "",
2571
+ session_key: str = "",
2572
+ metadata: Optional[Dict[str, Any]] = None,
2573
+ ) -> SendResult:
2574
+ """Send a Yes/No update-confirmation prompt with inline buttons.
2575
+
2576
+ Matches the cross-adapter contract used by
2577
+ ``gateway/run.py``'s ``hermes update --gateway`` watcher. Button
2578
+ clicks surface as ``INTERACTION_CREATE`` with
2579
+ ``button_data = 'update_prompt:y'`` or ``'update_prompt:n'``;
2580
+ the adapter's interaction callback writes the answer to
2581
+ ``~/.hermes/.update_response`` so the detached update process
2582
+ can read it.
2583
+ """
2584
+ del session_key, metadata # present for contract parity only.
2585
+
2586
+ default_hint = f" (default: {default})" if default else ""
2587
+ content = f"⚕ **Update Needs Your Input**\n\n{prompt}{default_hint}"
2588
+ msg_id = self._last_msg_id.get(chat_id)
2589
+ return await self.send_with_keyboard(
2590
+ chat_id,
2591
+ content,
2592
+ build_update_prompt_keyboard(),
2593
+ reply_to=msg_id,
2594
+ )
2595
+
2596
+ def _build_text_body(
2597
+ self, content: str, reply_to: Optional[str] = None
2598
+ ) -> Dict[str, Any]:
2599
+ """Build the message body for C2C/group text sending."""
2600
+ msg_seq = self._next_msg_seq(reply_to or "default")
2601
+
2602
+ if self._markdown_support:
2603
+ body: Dict[str, Any] = {
2604
+ "markdown": {"content": content[: self.MAX_MESSAGE_LENGTH]},
2605
+ "msg_type": MSG_TYPE_MARKDOWN,
2606
+ "msg_seq": msg_seq,
2607
+ }
2608
+ else:
2609
+ body = {
2610
+ "content": content[: self.MAX_MESSAGE_LENGTH],
2611
+ "msg_type": MSG_TYPE_TEXT,
2612
+ "msg_seq": msg_seq,
2613
+ }
2614
+
2615
+ if reply_to:
2616
+ # For non-markdown mode, add message_reference
2617
+ if not self._markdown_support:
2618
+ body["message_reference"] = {"message_id": reply_to}
2619
+
2620
+ return body
2621
+
2622
+ # ------------------------------------------------------------------
2623
+ # Native media sending
2624
+ # ------------------------------------------------------------------
2625
+
2626
+ async def send_image(
2627
+ self,
2628
+ chat_id: str,
2629
+ image_url: str,
2630
+ caption: Optional[str] = None,
2631
+ reply_to: Optional[str] = None,
2632
+ metadata: Optional[Dict[str, Any]] = None,
2633
+ ) -> SendResult:
2634
+ """Send an image natively via QQ Bot API upload."""
2635
+ del metadata
2636
+
2637
+ result = await self._send_media(
2638
+ chat_id, image_url, MEDIA_TYPE_IMAGE, "image", caption, reply_to
2639
+ )
2640
+ if result.success or not self._is_url(image_url):
2641
+ return result
2642
+
2643
+ # Fallback to text URL
2644
+ logger.warning(
2645
+ "[%s] Image send failed, falling back to text: %s",
2646
+ self._log_tag,
2647
+ result.error,
2648
+ )
2649
+ fallback = f"{caption}\n{image_url}" if caption else image_url
2650
+ return await self.send(chat_id=chat_id, content=fallback, reply_to=reply_to)
2651
+
2652
+ async def send_image_file(
2653
+ self,
2654
+ chat_id: str,
2655
+ image_path: str,
2656
+ caption: Optional[str] = None,
2657
+ reply_to: Optional[str] = None,
2658
+ **kwargs,
2659
+ ) -> SendResult:
2660
+ """Send a local image file natively."""
2661
+ del kwargs
2662
+ return await self._send_media(
2663
+ chat_id, image_path, MEDIA_TYPE_IMAGE, "image", caption, reply_to
2664
+ )
2665
+
2666
+ async def send_voice(
2667
+ self,
2668
+ chat_id: str,
2669
+ audio_path: str,
2670
+ caption: Optional[str] = None,
2671
+ reply_to: Optional[str] = None,
2672
+ **kwargs,
2673
+ ) -> SendResult:
2674
+ """Send a voice message natively."""
2675
+ del kwargs
2676
+ return await self._send_media(
2677
+ chat_id, audio_path, MEDIA_TYPE_VOICE, "voice", caption, reply_to
2678
+ )
2679
+
2680
+ async def send_video(
2681
+ self,
2682
+ chat_id: str,
2683
+ video_path: str,
2684
+ caption: Optional[str] = None,
2685
+ reply_to: Optional[str] = None,
2686
+ **kwargs,
2687
+ ) -> SendResult:
2688
+ """Send a video natively."""
2689
+ del kwargs
2690
+ return await self._send_media(
2691
+ chat_id, video_path, MEDIA_TYPE_VIDEO, "video", caption, reply_to
2692
+ )
2693
+
2694
+ async def send_document(
2695
+ self,
2696
+ chat_id: str,
2697
+ file_path: str,
2698
+ caption: Optional[str] = None,
2699
+ file_name: Optional[str] = None,
2700
+ reply_to: Optional[str] = None,
2701
+ **kwargs,
2702
+ ) -> SendResult:
2703
+ """Send a file/document natively."""
2704
+ del kwargs
2705
+ return await self._send_media(
2706
+ chat_id,
2707
+ file_path,
2708
+ MEDIA_TYPE_FILE,
2709
+ "file",
2710
+ caption,
2711
+ reply_to,
2712
+ file_name=file_name,
2713
+ )
2714
+
2715
+ async def _send_media(
2716
+ self,
2717
+ chat_id: str,
2718
+ media_source: str,
2719
+ file_type: int,
2720
+ kind: str,
2721
+ caption: Optional[str] = None,
2722
+ reply_to: Optional[str] = None,
2723
+ file_name: Optional[str] = None,
2724
+ ) -> SendResult:
2725
+ """Upload media and send as a native message.
2726
+
2727
+ Upload strategy:
2728
+
2729
+ - **HTTP(S) URLs** → single ``POST /v2/{users|groups}/{id}/files``
2730
+ with ``url=...``. The QQ platform fetches the URL directly; fastest
2731
+ path when the source is already hosted.
2732
+ - **Local files** → three-step chunked upload (prepare / PUT parts /
2733
+ complete). Handles files up to the platform's ~100 MB per-file
2734
+ limit without the ~10 MB inline-base64 cap of the old adapter.
2735
+ """
2736
+ if not self.is_connected:
2737
+ if not await self._wait_for_reconnection():
2738
+ return SendResult(success=False, error="Not connected", retryable=True)
2739
+
2740
+ chat_type = self._guess_chat_type(chat_id)
2741
+ if chat_type == "guild":
2742
+ # Guild channels don't support native media upload in the same way.
2743
+ return SendResult(
2744
+ success=False,
2745
+ error="Guild media send not supported via this path",
2746
+ )
2747
+
2748
+ try:
2749
+ if self._is_url(media_source):
2750
+ # URL upload — let the platform fetch it directly.
2751
+ resolved_name = (
2752
+ file_name
2753
+ or Path(urlparse(media_source).path).name
2754
+ or "media"
2755
+ )
2756
+ upload = await self._upload_media(
2757
+ chat_type,
2758
+ chat_id,
2759
+ file_type,
2760
+ url=media_source,
2761
+ srv_send_msg=False,
2762
+ file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None,
2763
+ )
2764
+ else:
2765
+ # Local file — chunked upload (prepare / PUT parts / complete).
2766
+ resolved_name, upload = await self._upload_local_file(
2767
+ chat_type,
2768
+ chat_id,
2769
+ media_source,
2770
+ file_type,
2771
+ file_name,
2772
+ )
2773
+
2774
+ file_info = upload.get("file_info") or (
2775
+ upload.get("data", {}) or {}
2776
+ ).get("file_info")
2777
+ if not file_info:
2778
+ return SendResult(
2779
+ success=False,
2780
+ error=f"Upload returned no file_info: {upload}",
2781
+ )
2782
+
2783
+ # Send media message
2784
+ msg_seq = self._next_msg_seq(chat_id)
2785
+ body: Dict[str, Any] = {
2786
+ "msg_type": MSG_TYPE_MEDIA,
2787
+ "media": {"file_info": file_info},
2788
+ "msg_seq": msg_seq,
2789
+ }
2790
+ if caption:
2791
+ body["content"] = caption[: self.MAX_MESSAGE_LENGTH]
2792
+ if reply_to:
2793
+ body["msg_id"] = reply_to
2794
+
2795
+ send_data = await self._api_request(
2796
+ "POST",
2797
+ (
2798
+ f"/v2/users/{chat_id}/messages"
2799
+ if chat_type == "c2c"
2800
+ else f"/v2/groups/{chat_id}/messages"
2801
+ ),
2802
+ body,
2803
+ )
2804
+ return SendResult(
2805
+ success=True,
2806
+ message_id=str(send_data.get("id", uuid.uuid4().hex[:12])),
2807
+ raw_response=send_data,
2808
+ )
2809
+ except UploadDailyLimitExceededError as exc:
2810
+ # Non-retryable: daily quota hit. Give the caller actionable text
2811
+ # so the model can compose a helpful reply.
2812
+ logger.warning(
2813
+ "[%s] Daily upload limit exceeded for %s (%s)",
2814
+ self._log_tag, exc.file_name, exc.file_size_human,
2815
+ )
2816
+ return SendResult(
2817
+ success=False,
2818
+ error=(
2819
+ f"QQ daily upload limit exceeded for {exc.file_name!r} "
2820
+ f"({exc.file_size_human}). Retry tomorrow."
2821
+ ),
2822
+ retryable=False,
2823
+ )
2824
+ except UploadFileTooLargeError as exc:
2825
+ logger.warning(
2826
+ "[%s] File too large: %s (%s, platform limit %s)",
2827
+ self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human,
2828
+ )
2829
+ return SendResult(
2830
+ success=False,
2831
+ error=(
2832
+ f"{exc.file_name!r} ({exc.file_size_human}) exceeds the "
2833
+ f"QQ per-file upload limit ({exc.limit_human})."
2834
+ ),
2835
+ retryable=False,
2836
+ )
2837
+ except Exception as exc:
2838
+ logger.error("[%s] Media send failed: %s", self._log_tag, exc)
2839
+ return SendResult(success=False, error=str(exc))
2840
+
2841
+ async def _upload_local_file(
2842
+ self,
2843
+ chat_type: str,
2844
+ chat_id: str,
2845
+ media_source: str,
2846
+ file_type: int,
2847
+ file_name: Optional[str],
2848
+ ) -> Tuple[str, Dict[str, Any]]:
2849
+ """Chunked-upload a local file and return ``(resolved_name, complete_response)``.
2850
+
2851
+ The returned ``complete_response`` contains the ``file_info`` token
2852
+ that goes into the subsequent RichMedia message body.
2853
+
2854
+ :raises UploadDailyLimitExceededError: On biz_code 40093002.
2855
+ :raises UploadFileTooLargeError: When the file exceeds the platform limit.
2856
+ :raises FileNotFoundError: If the path does not exist.
2857
+ :raises ValueError: If the path looks like a placeholder (``<path>``).
2858
+ :raises RuntimeError: If the HTTP client is not initialized.
2859
+ """
2860
+ if not self._http_client:
2861
+ raise RuntimeError("HTTP client not initialized — not connected?")
2862
+
2863
+ local_path = Path(media_source).expanduser()
2864
+ if not local_path.is_absolute():
2865
+ local_path = (Path.cwd() / local_path).resolve()
2866
+
2867
+ if not local_path.exists() or not local_path.is_file():
2868
+ if media_source.startswith("<") or len(media_source) < 3:
2869
+ raise ValueError(
2870
+ f"Invalid media source (looks like a placeholder): {media_source!r}"
2871
+ )
2872
+ raise FileNotFoundError(f"Media file not found: {local_path}")
2873
+
2874
+ resolved_name = file_name or local_path.name
2875
+ uploader = ChunkedUploader(
2876
+ api_request=self._api_request,
2877
+ http_put=self._http_client.put,
2878
+ log_tag=self._log_tag,
2879
+ )
2880
+ complete = await uploader.upload(
2881
+ chat_type=chat_type,
2882
+ target_id=chat_id,
2883
+ file_path=str(local_path),
2884
+ file_type=file_type,
2885
+ file_name=resolved_name,
2886
+ )
2887
+ return resolved_name, complete
2888
+
2889
+ async def _load_media(
2890
+ self, source: str, file_name: Optional[str] = None
2891
+ ) -> Tuple[str, str, str]:
2892
+ """Load media from URL or local path. Returns (base64_or_url, content_type, filename)."""
2893
+ source = str(source).strip()
2894
+ if not source:
2895
+ raise ValueError("Media source is required")
2896
+
2897
+ parsed = urlparse(source)
2898
+ if parsed.scheme in {"http", "https"}:
2899
+ # For URLs, pass through directly to the upload API
2900
+ content_type = mimetypes.guess_type(source)[0] or "application/octet-stream"
2901
+ resolved_name = file_name or Path(parsed.path).name or "media"
2902
+ return source, content_type, resolved_name
2903
+
2904
+ # Local file — encode as raw base64 for QQ Bot API file_data field.
2905
+ # The QQ API expects plain base64, NOT a data URI.
2906
+ local_path = Path(source).expanduser()
2907
+ if not local_path.is_absolute():
2908
+ local_path = (Path.cwd() / local_path).resolve()
2909
+
2910
+ if not local_path.exists() or not local_path.is_file():
2911
+ # Guard against placeholder paths like "<path>" that the LLM
2912
+ # sometimes emits instead of real file paths.
2913
+ if source.startswith("<") or len(source) < 3:
2914
+ raise ValueError(
2915
+ f"Invalid media source (looks like a placeholder): {source!r}"
2916
+ )
2917
+ raise FileNotFoundError(f"Media file not found: {local_path}")
2918
+
2919
+ raw = local_path.read_bytes()
2920
+ resolved_name = file_name or local_path.name
2921
+ content_type = (
2922
+ mimetypes.guess_type(str(local_path))[0] or "application/octet-stream"
2923
+ )
2924
+ b64 = base64.b64encode(raw).decode("ascii")
2925
+ return b64, content_type, resolved_name
2926
+
2927
+ # ------------------------------------------------------------------
2928
+ # Typing indicator
2929
+ # ------------------------------------------------------------------
2930
+
2931
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
2932
+ """Send an input notify to a C2C user (only supported for C2C).
2933
+
2934
+ Debounced to one request per ~50s (the API sets a 60s indicator).
2935
+ The QQ API requires the originating message ID — retrieved from
2936
+ ``_last_msg_id`` which is populated by ``_on_message``.
2937
+ """
2938
+ if not self.is_connected:
2939
+ return
2940
+
2941
+ chat_type = self._guess_chat_type(chat_id)
2942
+ if chat_type != "c2c":
2943
+ return
2944
+
2945
+ msg_id = self._last_msg_id.get(chat_id)
2946
+ if not msg_id:
2947
+ return
2948
+
2949
+ # Debounce — skip if we sent recently
2950
+ now = time.time()
2951
+ last_sent = self._typing_sent_at.get(chat_id, 0.0)
2952
+ if now - last_sent < self._TYPING_DEBOUNCE_SECONDS:
2953
+ return
2954
+
2955
+ try:
2956
+ msg_seq = self._next_msg_seq(chat_id)
2957
+ body = {
2958
+ "msg_type": MSG_TYPE_INPUT_NOTIFY,
2959
+ "msg_id": msg_id,
2960
+ "input_notify": {
2961
+ "input_type": 1,
2962
+ "input_second": self._TYPING_INPUT_SECONDS,
2963
+ },
2964
+ "msg_seq": msg_seq,
2965
+ }
2966
+ await self._api_request("POST", f"/v2/users/{chat_id}/messages", body)
2967
+ self._typing_sent_at[chat_id] = now
2968
+ except Exception as exc:
2969
+ logger.debug("[%s] send_typing failed: %s", self._log_tag, exc)
2970
+
2971
+ # ------------------------------------------------------------------
2972
+ # Format
2973
+ # ------------------------------------------------------------------
2974
+
2975
+ def format_message(self, content: str) -> str:
2976
+ """Format message for QQ.
2977
+
2978
+ When markdown_support is enabled, content is sent as-is (QQ renders it).
2979
+ When disabled, strip markdown via shared helper (same as BlueBubbles/SMS).
2980
+ """
2981
+ if self._markdown_support:
2982
+ return content
2983
+ return strip_markdown(content)
2984
+
2985
+ # ------------------------------------------------------------------
2986
+ # Chat info
2987
+ # ------------------------------------------------------------------
2988
+
2989
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
2990
+ """Return chat info based on chat type heuristics."""
2991
+ chat_type = self._guess_chat_type(chat_id)
2992
+ return {
2993
+ "name": chat_id,
2994
+ "type": "group" if chat_type in {"group", "guild"} else "dm",
2995
+ }
2996
+
2997
+ # ------------------------------------------------------------------
2998
+ # Helpers
2999
+ # ------------------------------------------------------------------
3000
+
3001
+ @staticmethod
3002
+ def _is_url(source: str) -> bool:
3003
+ return urlparse(str(source)).scheme in {"http", "https"}
3004
+
3005
+ def _guess_chat_type(self, chat_id: str) -> str:
3006
+ """Determine chat type from stored inbound metadata, fallback to 'c2c'."""
3007
+ if chat_id in self._chat_type_map:
3008
+ return self._chat_type_map[chat_id]
3009
+ return "c2c"
3010
+
3011
+ @staticmethod
3012
+ def _strip_at_mention(content: str) -> str:
3013
+ """Strip the @bot mention prefix from group message content."""
3014
+ # QQ group @-messages may have the bot's QQ/ID as prefix
3015
+ import re
3016
+
3017
+ stripped = re.sub(r"^@\S+\s*", "", content.strip())
3018
+ return stripped
3019
+
3020
+ def _is_dm_allowed(self, user_id: str) -> bool:
3021
+ if self._dm_policy == "disabled":
3022
+ return False
3023
+ if self._dm_policy == "allowlist":
3024
+ return self._entry_matches(self._allow_from, user_id)
3025
+ return True
3026
+
3027
+ def _is_group_allowed(self, group_id: str, user_id: str) -> bool:
3028
+ if self._group_policy == "disabled":
3029
+ return False
3030
+ if self._group_policy == "allowlist":
3031
+ return self._entry_matches(self._group_allow_from, group_id)
3032
+ return True
3033
+
3034
+ @staticmethod
3035
+ def _entry_matches(entries: List[str], target: str) -> bool:
3036
+ normalized_target = str(target).strip().lower()
3037
+ for entry in entries:
3038
+ normalized = str(entry).strip().lower()
3039
+ if normalized == "*" or normalized == normalized_target:
3040
+ return True
3041
+ return False
3042
+
3043
+ def _parse_qq_timestamp(self, raw: str) -> datetime:
3044
+ """Parse QQ API timestamp (ISO 8601 string or integer ms).
3045
+
3046
+ The QQ API changed from integer milliseconds to ISO 8601 strings.
3047
+ This handles both formats gracefully.
3048
+ """
3049
+ if not raw:
3050
+ return datetime.now(tz=timezone.utc)
3051
+ try:
3052
+ return datetime.fromisoformat(raw)
3053
+ except (ValueError, TypeError):
3054
+ pass
3055
+ try:
3056
+ return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc)
3057
+ except (ValueError, TypeError):
3058
+ pass
3059
+ return datetime.now(tz=timezone.utc)
3060
+
3061
+ def _is_duplicate(self, msg_id: str) -> bool:
3062
+ now = time.time()
3063
+ if len(self._seen_messages) > DEDUP_MAX_SIZE:
3064
+ cutoff = now - DEDUP_WINDOW_SECONDS
3065
+ self._seen_messages = {
3066
+ key: ts for key, ts in self._seen_messages.items() if ts > cutoff
3067
+ }
3068
+ if msg_id in self._seen_messages:
3069
+ return True
3070
+ self._seen_messages[msg_id] = now
3071
+ return False
3072
+