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,3343 @@
1
+ """
2
+ Google Chat platform adapter.
3
+
4
+ Uses Google Cloud Pub/Sub (pull subscription) for inbound events and the
5
+ Google Chat REST API for outbound messages. Pattern parallels Slack Socket
6
+ Mode and Telegram long-polling: no public endpoint required.
7
+
8
+ Concurrency model
9
+ -----------------
10
+ The Pub/Sub SubscriberClient invokes its message callback in a background
11
+ thread (managed by the client's internal executor). The adapter's
12
+ ``handle_message`` coroutine must run on the asyncio event loop, so the
13
+ callback uses ``asyncio.run_coroutine_threadsafe`` with
14
+ ``add_done_callback`` (never ``.result()`` — that would block the callback
15
+ thread and saturate the Pub/Sub executor under load).
16
+
17
+ All outbound Chat REST calls go through ``asyncio.to_thread`` because the
18
+ googleapiclient is synchronous. This keeps the event loop responsive.
19
+
20
+ Pub/Sub delivery diagram::
21
+
22
+ Pub/Sub stream -> callback thread -> asyncio loop
23
+ (streaming_pull) (_on_pubsub_message) (handle_message)
24
+ | | |
25
+ | at-least-once | parse + dedup | agent work
26
+ | delivery | _submit_on_loop | send() response
27
+ | | message.ack() |
28
+ v v v
29
+
30
+ Event type routing
31
+ ------------------
32
+ Inbound envelope carries ``type`` in [MESSAGE, ADDED_TO_SPACE, REMOVED_FROM_SPACE,
33
+ CARD_CLICKED]. Only MESSAGE dispatches to the agent. ADDED_TO_SPACE caches the
34
+ bot's resource name (belt-and-suspenders on top of eager resolution in connect()).
35
+ CARD_CLICKED is ACK'd only in v1 (follow-up PR implements interactivity).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import json
42
+ import logging
43
+ import os
44
+ import random
45
+ import re
46
+ from pathlib import Path as _Path
47
+ from typing import Any, Callable, Dict, List, Optional, Tuple
48
+
49
+ # Heavy google-cloud + googleapiclient imports are deferred to first
50
+ # adapter use. Importing them eagerly here added ~110ms wall and ~33MB
51
+ # RSS to *every* CLI invocation (the plugin loader imports this module at
52
+ # ``model_tools`` import time, so ``hermes status``, ``hermes chat``, etc.
53
+ # all paid the cost even though they never instantiate the adapter).
54
+ #
55
+ # All names below are module globals that ``_load_google_modules()``
56
+ # rebinds on first call. The ``HttpError = Exception`` placeholder is
57
+ # important: ``except HttpError as exc:`` clauses elsewhere in this
58
+ # module bind the *current* module-global at try/except evaluation time,
59
+ # so as long as ``_load_google_modules()`` runs before any such
60
+ # ``try`` block executes (which it does — ``__init__`` calls it), the
61
+ # rebound real ``googleapiclient.errors.HttpError`` is what actually
62
+ # matches at runtime.
63
+ GOOGLE_CHAT_AVAILABLE: bool = False
64
+ httplib2: Any = None # type: ignore
65
+ pubsub_v1: Any = None # type: ignore
66
+ gax_exceptions: Any = None # type: ignore
67
+ service_account: Any = None # type: ignore
68
+ AuthorizedHttp: Any = None # type: ignore
69
+ build_service: Any = None # type: ignore
70
+ HttpError: Any = Exception # type: ignore
71
+ MediaFileUpload: Any = None # type: ignore
72
+
73
+ _google_modules_loaded: bool = False
74
+
75
+
76
+ def _load_google_modules() -> bool:
77
+ """Lazily import the heavy google-cloud + googleapiclient stack.
78
+
79
+ Idempotent. Returns True if the optional deps are installed and
80
+ were successfully imported, False otherwise. On success, mutates
81
+ the module globals so existing code using ``pubsub_v1``,
82
+ ``service_account``, ``HttpError``, etc. transparently uses the
83
+ real classes.
84
+
85
+ Why deferred: the import chain pulls in google.cloud.pubsub_v1,
86
+ googleapiclient, grpc, and friends — about 33MB RSS and 110ms wall
87
+ on a fresh interpreter. Plugin discovery imports this module on
88
+ every CLI invocation, even ones that never touch a gateway.
89
+ """
90
+ global GOOGLE_CHAT_AVAILABLE, _google_modules_loaded
91
+ global httplib2, pubsub_v1, gax_exceptions, service_account
92
+ global AuthorizedHttp, build_service, HttpError, MediaFileUpload
93
+ if _google_modules_loaded:
94
+ return GOOGLE_CHAT_AVAILABLE
95
+ _google_modules_loaded = True
96
+ try:
97
+ import httplib2 as _httplib2
98
+ from google.cloud import pubsub_v1 as _pubsub_v1
99
+ from google.api_core import exceptions as _gax_exceptions
100
+ from google.oauth2 import service_account as _service_account
101
+ from google_auth_httplib2 import AuthorizedHttp as _AuthorizedHttp
102
+ from googleapiclient.discovery import build as _build_service
103
+ from googleapiclient.errors import HttpError as _HttpError
104
+ from googleapiclient.http import MediaFileUpload as _MediaFileUpload
105
+ except ImportError:
106
+ GOOGLE_CHAT_AVAILABLE = False
107
+ return False
108
+ httplib2 = _httplib2
109
+ pubsub_v1 = _pubsub_v1
110
+ gax_exceptions = _gax_exceptions
111
+ service_account = _service_account
112
+ AuthorizedHttp = _AuthorizedHttp
113
+ build_service = _build_service
114
+ HttpError = _HttpError
115
+ MediaFileUpload = _MediaFileUpload
116
+ GOOGLE_CHAT_AVAILABLE = True
117
+ return True
118
+
119
+ from gateway.config import Platform, PlatformConfig
120
+
121
+ # Trigger registration of the dynamic ``google_chat`` enum member at module
122
+ # import time. ``_missing_()`` caches the pseudo-member in
123
+ # ``_value2member_map_`` *and* ``_member_map_``, so after this call
124
+ # ``Platform.GOOGLE_CHAT`` resolves via attribute access too. Without this
125
+ # line, any code (including tests) that references ``Platform.GOOGLE_CHAT``
126
+ # before an adapter instance is constructed would hit ``AttributeError``.
127
+ # Built-ins avoid this because they have explicit enum members; plugin
128
+ # platforms earn the attribute by asking for it once.
129
+ Platform("google_chat")
130
+ from gateway.platforms.helpers import MessageDeduplicator
131
+ from gateway.platforms.base import (
132
+ BasePlatformAdapter,
133
+ MessageEvent,
134
+ MessageType,
135
+ ProcessingOutcome,
136
+ SendResult,
137
+ cache_audio_from_bytes,
138
+ cache_document_from_bytes,
139
+ cache_image_from_bytes,
140
+ cache_video_from_bytes,
141
+ )
142
+
143
+
144
+ # Pin the logger name to the legacy module path so operator log filters,
145
+ # grep aliases, and the gateway's bundled log views keep matching after
146
+ # the in-tree → plugin migration. ``__name__`` resolves to
147
+ # ``hermes_plugins.platforms__google_chat.adapter`` once the plugin
148
+ # loader namespaces this module, which would silently break every
149
+ # downstream log-monitor that greps for ``gateway.platforms.google_chat``.
150
+ logger = logging.getLogger("gateway.platforms.google_chat")
151
+
152
+
153
+ # Regex validating Pub/Sub subscription path format.
154
+ _SUBSCRIPTION_PATH_RE = re.compile(
155
+ r"^projects/(?P<project>[^/]+)/subscriptions/(?P<sub>[^/]+)$"
156
+ )
157
+
158
+ # SA scopes — chat.bot is sufficient for the bot's own messaging operations
159
+ # (messages.create / patch / delete, spaces metadata, memberships,
160
+ # media.download for inbound user attachments). The bot CANNOT call
161
+ # media.upload — Google requires user OAuth for that endpoint, no scope
162
+ # adjustment changes it.
163
+ #
164
+ # Native attachment delivery (bot → user) is handled via a separate user-
165
+ # OAuth flow in ``oauth.py`` (this plugin's helper module): the user grants the bot
166
+ # the chat.messages.create scope ONCE via an in-chat consent flow; the
167
+ # bot then calls media.upload on the user's behalf when sending files.
168
+ # See https://developers.google.com/chat/api/guides/auth/users
169
+ _CHAT_SCOPES = [
170
+ "https://www.googleapis.com/auth/chat.bot",
171
+ "https://www.googleapis.com/auth/pubsub",
172
+ ]
173
+
174
+ # Google Chat text-message size limit is 4096; leave margin.
175
+ _MAX_TEXT_LENGTH = 4000
176
+
177
+ # Per-space rate-limit hit counter threshold; warn if exceeded.
178
+ _RATE_LIMIT_WARN_THRESHOLD = 5
179
+
180
+ # Outbound retry parameters. Google's Chat REST API returns transient 5xx
181
+ # and 429 occasionally — without a retry wrapper, single hiccups drop
182
+ # user-visible messages. Backoff stays bounded so a true outage is still
183
+ # surfaced quickly. Pattern lifted from PR #14965.
184
+ _RETRY_MAX_ATTEMPTS = 3
185
+ _RETRY_BASE_DELAY = 1.0
186
+ _RETRY_MAX_DELAY = 8.0
187
+ _RETRY_JITTER = 0.3
188
+ _RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504})
189
+
190
+
191
+ def _is_retryable_error(exc: BaseException) -> bool:
192
+ """Classify outbound API errors as transient (retryable) vs permanent.
193
+
194
+ Retries are applied to:
195
+ - HTTP 429 (rate-limited)
196
+ - HTTP 5xx (server errors)
197
+ - Network/transport failures (timeout, connection reset, DNS)
198
+
199
+ Authentication errors (401/403), client errors (4xx other than 429),
200
+ and well-formed non-retryable failures are NOT retried — those
201
+ indicate a misconfiguration or revoked token, not a hiccup.
202
+ """
203
+ # googleapiclient.errors.HttpError carries resp.status
204
+ resp = getattr(exc, "resp", None)
205
+ status = getattr(resp, "status", None)
206
+ if isinstance(status, int):
207
+ return status in _RETRYABLE_HTTP_STATUSES
208
+ # Fallback heuristics for SSL/socket errors that don't carry an
209
+ # HTTP status: text matches against common transport-layer wording.
210
+ text = str(exc).lower()
211
+ if "timeout" in text or "timed out" in text:
212
+ return True
213
+ if "connection" in text and ("reset" in text or "refused" in text or "aborted" in text):
214
+ return True
215
+ if "broken pipe" in text or "remote disconnected" in text:
216
+ return True
217
+ return False
218
+
219
+ # Sentinel kept in ``_typing_messages`` after ``send()`` patches the typing
220
+ # marker into the agent's real response. Two purposes:
221
+ # * ``send_typing`` checks for any value before posting — sentinel keeps
222
+ # ``_keep_typing`` (running on the base-class timer) from creating a
223
+ # fresh "Hermes is thinking…" card during the small window between
224
+ # ``send()`` finishing and the base-class cancelling its typing_task.
225
+ # * ``stop_typing`` checks for the sentinel and skips the API delete —
226
+ # otherwise the safety-net cleanup at base.py:_process_message_background
227
+ # would delete the response we just patched and leave a tombstone.
228
+ _TYPING_CONSUMED_SENTINEL = "<consumed>"
229
+
230
+
231
+ def check_google_chat_requirements() -> bool:
232
+ """Check if Google Chat optional dependencies are installed.
233
+
234
+ Triggers the lazy import of the google-cloud + googleapiclient stack
235
+ on first call. Subsequent calls hit the cached result. This is the
236
+ canonical "are the deps available" probe used by the plugin registry
237
+ and the adapter's own startup gate.
238
+ """
239
+ return _load_google_modules()
240
+
241
+
242
+ # Hostnames we trust to host Google Chat attachment download URIs. Anything
243
+ # else gets rejected by _is_google_owned_host to block SSRF scenarios where
244
+ # a crafted event points downloadUri at a non-Google endpoint (e.g. the
245
+ # GCE/GKE metadata service at 169.254.169.254) and the bot's Service Account
246
+ # bearer token would be attached to the outbound request.
247
+ _TRUSTED_ATTACHMENT_HOSTS = (
248
+ "googleapis.com",
249
+ "chat.google.com",
250
+ "drive.google.com",
251
+ "docs.google.com",
252
+ "lh3.googleusercontent.com",
253
+ "lh4.googleusercontent.com",
254
+ "lh5.googleusercontent.com",
255
+ "lh6.googleusercontent.com",
256
+ )
257
+
258
+
259
+ def _is_google_owned_host(url: str) -> bool:
260
+ """Return True iff *url* is https and targets a Google-owned domain."""
261
+ try:
262
+ from urllib.parse import urlparse
263
+
264
+ parsed = urlparse(url)
265
+ except Exception:
266
+ return False
267
+ if parsed.scheme != "https":
268
+ return False
269
+ host = (parsed.hostname or "").lower()
270
+ if not host:
271
+ return False
272
+ return any(host == h or host.endswith("." + h) for h in _TRUSTED_ATTACHMENT_HOSTS)
273
+
274
+
275
+ def _redact_sensitive(text: str) -> str:
276
+ """Sanitize subscription paths and email-like tokens from an error string.
277
+
278
+ Covers project IDs leaking via Pub/Sub exception messages, plus SA-ish
279
+ email addresses. agent/redact.py handles log-level redaction elsewhere;
280
+ this helper is for user-facing error messages.
281
+ """
282
+ if not text:
283
+ return text
284
+ text = re.sub(
285
+ r"projects/[^/\s]+/subscriptions/[^/\s]+",
286
+ "projects/<redacted>/subscriptions/<redacted>",
287
+ text,
288
+ )
289
+ text = re.sub(
290
+ r"projects/[^/\s]+/topics/[^/\s]+",
291
+ "projects/<redacted>/topics/<redacted>",
292
+ text,
293
+ )
294
+ text = re.sub(
295
+ r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com",
296
+ "<sa>@<project>.iam.gserviceaccount.com",
297
+ text,
298
+ )
299
+ return text
300
+
301
+
302
+ def _mime_for_message_type(mime: str) -> MessageType:
303
+ """Map a MIME string to a hermes MessageType.
304
+
305
+ Anything not image/audio/video falls through to DOCUMENT so the agent
306
+ still receives the file.
307
+ """
308
+ if not mime:
309
+ return MessageType.DOCUMENT
310
+ if mime.startswith("image/"):
311
+ return MessageType.PHOTO
312
+ if mime.startswith("audio/"):
313
+ return MessageType.AUDIO
314
+ if mime.startswith("video/"):
315
+ return MessageType.VIDEO
316
+ return MessageType.DOCUMENT
317
+
318
+
319
+ class _ThreadCountStore:
320
+ """Per-(chat_id, thread_name) inbound message counter, persisted to disk.
321
+
322
+ Drives the DM main-flow vs side-thread heuristic:
323
+
324
+ - prev_count == 0 (first time we see this thread) → "main flow":
325
+ Google Chat just auto-created a fresh thread for the user's
326
+ top-level message. Treat it as part of the shared DM session;
327
+ bot replies at top-level (no thread.name on outbound).
328
+ - prev_count >= 1 (we've already seen this thread) → "side thread":
329
+ user explicitly engaged a thread that's been around. Isolate
330
+ session by thread, route bot reply into the same thread.
331
+
332
+ Persistence is essential: without it, every gateway restart wipes
333
+ counts and active side-threads silently demote to "main flow",
334
+ which leaks main-flow context into the user's isolated thread
335
+ (the bug Ramón reported across 4 iterations of the in-memory
336
+ version).
337
+
338
+ File format (JSON):
339
+ {"<chat_id>": {"<thread_name>": <int_count>, ...}, ...}
340
+
341
+ Failure modes are non-fatal: a missing or corrupt file resets to
342
+ empty (logged as warning) so the adapter never crashes on disk
343
+ issues. The next ``incr`` will write a fresh file.
344
+
345
+ Save strategy: write-through after every ``incr``. The file is
346
+ tiny (a few KB even for very active bots), so the simplicity of
347
+ write-through outweighs the cost of debouncing for now.
348
+ """
349
+
350
+ def __init__(self, path: _Path):
351
+ self._path = path
352
+ self._counts: Dict[str, Dict[str, int]] = {}
353
+ self._loaded = False
354
+
355
+ def load(self) -> None:
356
+ """Load counts from disk. Safe to call multiple times.
357
+
358
+ Missing file → empty store. Corrupt JSON → empty store + warn.
359
+ """
360
+ self._loaded = True
361
+ if not self._path.exists():
362
+ self._counts = {}
363
+ return
364
+ try:
365
+ raw = self._path.read_text()
366
+ data = json.loads(raw) if raw.strip() else {}
367
+ except json.JSONDecodeError as exc:
368
+ logger.warning(
369
+ "[GoogleChat] thread-count store at %s is corrupt; "
370
+ "starting fresh: %s",
371
+ self._path, exc,
372
+ )
373
+ self._counts = {}
374
+ return
375
+ except OSError as exc:
376
+ logger.warning(
377
+ "[GoogleChat] could not read thread-count store at %s: %s",
378
+ self._path, exc,
379
+ )
380
+ self._counts = {}
381
+ return
382
+ # Validate shape — anything off-schema gets dropped silently.
383
+ clean: Dict[str, Dict[str, int]] = {}
384
+ if isinstance(data, dict):
385
+ for chat_id, threads in data.items():
386
+ if not isinstance(chat_id, str) or not isinstance(threads, dict):
387
+ continue
388
+ clean_threads: Dict[str, int] = {}
389
+ for thread_name, count in threads.items():
390
+ if isinstance(thread_name, str) and isinstance(count, int):
391
+ clean_threads[thread_name] = count
392
+ if clean_threads:
393
+ clean[chat_id] = clean_threads
394
+ self._counts = clean
395
+
396
+ def get(self, chat_id: str, thread_name: str) -> int:
397
+ """Return the current count for (chat_id, thread_name), or 0."""
398
+ return self._counts.get(chat_id, {}).get(thread_name, 0)
399
+
400
+ def incr(self, chat_id: str, thread_name: str) -> int:
401
+ """Increment count and write through to disk. Returns the
402
+ PRE-increment value (the heuristic input — "have we seen this
403
+ thread before this message?")."""
404
+ chat_counts = self._counts.setdefault(chat_id, {})
405
+ prev = chat_counts.get(thread_name, 0)
406
+ chat_counts[thread_name] = prev + 1
407
+ self._save()
408
+ return prev
409
+
410
+ def _save(self) -> None:
411
+ """Atomic write of the counts dict to disk.
412
+
413
+ Failure is non-fatal — log warning and continue. The in-memory
414
+ counts stay consistent within the running process; only restart
415
+ recovery is affected.
416
+ """
417
+ try:
418
+ self._path.parent.mkdir(parents=True, exist_ok=True)
419
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
420
+ tmp.write_text(json.dumps(self._counts, separators=(",", ":")))
421
+ os.replace(tmp, self._path)
422
+ except OSError as exc:
423
+ logger.warning(
424
+ "[GoogleChat] could not persist thread-count store to %s: %s",
425
+ self._path, exc,
426
+ )
427
+
428
+
429
+ class GoogleChatAdapter(BasePlatformAdapter):
430
+ """
431
+ Google Chat bot adapter using Pub/Sub pull + Chat REST API.
432
+
433
+ Required environment (see gateway/config.py Google Chat block):
434
+ GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT fallback)
435
+ GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION fallback)
436
+ GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (or GOOGLE_APPLICATION_CREDENTIALS)
437
+
438
+ Optional:
439
+ GOOGLE_CHAT_ALLOWED_USERS, GOOGLE_CHAT_ALLOW_ALL_USERS
440
+ GOOGLE_CHAT_HOME_CHANNEL
441
+ GOOGLE_CHAT_MAX_MESSAGES (FlowControl, default 1)
442
+ GOOGLE_CHAT_MAX_BYTES (FlowControl, default 16_777_216 = 16 MiB)
443
+ """
444
+
445
+ MAX_MESSAGE_LENGTH = _MAX_TEXT_LENGTH
446
+ # Pub/Sub supervisor configuration.
447
+ _MAX_RECONNECT_ATTEMPTS = 10
448
+ _RECONNECT_BASE_DELAY = 2.0
449
+ _RECONNECT_MAX_DELAY = 120.0
450
+
451
+ def __init__(self, config: PlatformConfig):
452
+ # ``Platform("google_chat")`` resolves via ``_missing_()`` → pseudo-member
453
+ # cached in ``_value2member_map_``. We deliberately do NOT add an enum
454
+ # attribute to ``gateway.config.Platform`` — bundled platform plugins
455
+ # are looked up by value, not attribute (matches Teams, IRC).
456
+ super().__init__(config, Platform("google_chat"))
457
+ # Trigger the deferred google-cloud + googleapiclient import here so
458
+ # that any code path which constructs the adapter and then calls
459
+ # methods directly (notably the test suite, which builds an adapter
460
+ # and invokes ``_send_file`` / ``_create_message`` / etc. without
461
+ # going through ``connect()``) sees real classes for ``MediaFileUpload``,
462
+ # ``service_account``, ``HttpError``, and friends. The module-level
463
+ # globals were previously eager-imported; making this lazy saved
464
+ # ~110ms / ~33MB on every CLI invocation. Idempotent — pays the cost
465
+ # exactly once per process.
466
+ _load_google_modules()
467
+ self._subscriber: Optional[Any] = None
468
+ self._chat_api: Optional[Any] = None
469
+ # User-authed Chat API client built lazily from the OAuth refresh
470
+ # token persisted by the plugin's ``oauth.py`` helper. Required for
471
+ # native ``media.upload`` (bot identity is rejected by that
472
+ # endpoint).
473
+ #
474
+ # Multi-user mode: each user runs ``/setup-files`` ONCE in their
475
+ # own DM and the resulting refresh token is stored under their
476
+ # email. ``_send_file`` looks up the requesting user's email via
477
+ # ``_last_sender_by_chat`` and uses THAT user's token, so when
478
+ # User B asks for a file in B's DM the bot uploads as B (not as
479
+ # whoever first set up files long ago).
480
+ #
481
+ # ``_user_credentials`` / ``_user_chat_api`` keep their old names
482
+ # but now hold the LEGACY single-user token (if any) — used as a
483
+ # last-ditch fallback when the requesting user has no per-user
484
+ # token yet. Pre-multi-user installs continue to work unchanged.
485
+ self._user_chat_api: Optional[Any] = None
486
+ self._user_credentials: Optional[Any] = None
487
+ # Per-email caches. Populated lazily by ``_get_user_chat_for_chat``.
488
+ self._user_creds_by_email: Dict[str, Any] = {}
489
+ self._user_chat_api_by_email: Dict[str, Any] = {}
490
+ # chat_id → most-recent inbound sender's email. Populated in
491
+ # ``_build_message_event`` whenever the inbound event carries a
492
+ # non-empty ``sender.email``. Drives the per-user token lookup
493
+ # in ``_send_file`` so the bot uploads as the user who triggered
494
+ # the request, not as some other authorized user.
495
+ self._last_sender_by_chat: Dict[str, str] = {}
496
+ self._credentials: Optional[Any] = None
497
+ self._project_id: Optional[str] = None
498
+ self._subscription_path: Optional[str] = None
499
+ self._streaming_pull_future: Optional[Any] = None
500
+ self._supervisor_task: Optional[asyncio.Task] = None
501
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
502
+ self._bot_user_id: Optional[str] = None # users/{id}
503
+ self._dedup = MessageDeduplicator()
504
+ self._typing_messages: Dict[str, str] = {}
505
+ self._shutting_down = False
506
+ self._rate_limit_hits: Dict[str, int] = {}
507
+ # Last-seen inbound thread name per chat_id (space). Google Chat
508
+ # DMs create a NEW thread per top-level user message but the user
509
+ # views them as one logical conversation. We:
510
+ # (a) drop thread_id from the source for DMs (so session_key
511
+ # stays stable across top-level messages — see
512
+ # gateway/session.py:build_session_key).
513
+ # (b) cache the most recent inbound thread name here so outbound
514
+ # replies still land in the right visual thread without
515
+ # re-coupling sessions to threads.
516
+ self._last_inbound_thread: Dict[str, str] = {}
517
+ # Inbound message count per (chat_id, thread_name). Drives the
518
+ # DM main-flow vs side-thread heuristic in _build_message_event
519
+ # and the outbound thread routing in _resolve_thread_id.
520
+ # Persisted to ${HERMES_HOME}/google_chat_thread_counts.json so
521
+ # active side-threads survive gateway restarts (the bug that
522
+ # made the in-memory version of this heuristic flaky for
523
+ # multi-restart sessions).
524
+ try:
525
+ from calvyn_constants import get_hermes_home as _get_hermes_home
526
+ _hermes_home = _get_hermes_home()
527
+ except (ModuleNotFoundError, ImportError):
528
+ _hermes_home = _Path.home() / ".hermes"
529
+ self._thread_count_store = _ThreadCountStore(
530
+ _hermes_home / "google_chat_thread_counts.json"
531
+ )
532
+ # In-flight typing-card creates per chat_id. send_typing() reserves
533
+ # an Event here BEFORE starting the API call so concurrent calls
534
+ # from base.py's _keep_typing wait instead of duplicating cards.
535
+ # Cleared in the create_and_record finally.
536
+ self._typing_card_inflight: Dict[str, asyncio.Event] = {}
537
+ # Orphaned typing cards (created by background tasks that lost a
538
+ # race with send() / another concurrent create). Cleaned up at
539
+ # end-of-turn by on_processing_complete via patch-to-empty so
540
+ # they don't sit in the chat forever as "Hermes is thinking…".
541
+ self._orphan_typing_messages: Dict[str, List[str]] = {}
542
+ # FlowControl knobs (env-configurable).
543
+ self._max_messages = int(os.getenv("GOOGLE_CHAT_MAX_MESSAGES", "1"))
544
+ self._max_bytes = int(os.getenv("GOOGLE_CHAT_MAX_BYTES", str(16 * 1024 * 1024)))
545
+
546
+ # ------------------------------------------------------------------
547
+ # Configuration loading and validation
548
+ # ------------------------------------------------------------------
549
+ def _load_sa_credentials(self) -> Any:
550
+ """Load Service Account credentials from env or config.extra,
551
+ falling back to Application Default Credentials.
552
+
553
+ Priority:
554
+ 1. Explicit ``extra['service_account_json']`` (path or inline JSON)
555
+ 2. ``GOOGLE_APPLICATION_CREDENTIALS`` env var (path)
556
+ 3. Application Default Credentials via ``google.auth.default()``
557
+ — works on Cloud Run / GCE / GKE with a workload identity
558
+ attached, or locally via ``gcloud auth application-default
559
+ login``. Lets operators run the gateway in GCP without
560
+ managing SA key files. Pattern lifted from PR #14965.
561
+ """
562
+ sa_path = (
563
+ self.config.extra.get("service_account_json")
564
+ or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
565
+ )
566
+ if sa_path:
567
+ # Inline JSON (rare, but supported).
568
+ if sa_path.lstrip().startswith("{"):
569
+ try:
570
+ info = json.loads(sa_path)
571
+ except json.JSONDecodeError as exc:
572
+ raise ValueError(
573
+ f"Inline SA JSON is not valid JSON: {exc}"
574
+ ) from exc
575
+ return service_account.Credentials.from_service_account_info(
576
+ info, scopes=_CHAT_SCOPES
577
+ )
578
+ if not os.path.exists(sa_path):
579
+ raise FileNotFoundError(
580
+ f"Service Account JSON file not found at configured path."
581
+ )
582
+ # Validate file parses before handing to google-auth for nicer error.
583
+ try:
584
+ with open(sa_path, "r", encoding="utf-8") as fh:
585
+ info = json.load(fh)
586
+ except json.JSONDecodeError as exc:
587
+ raise ValueError(
588
+ f"Service Account JSON file is not valid JSON: {exc}"
589
+ ) from exc
590
+ return service_account.Credentials.from_service_account_info(
591
+ info, scopes=_CHAT_SCOPES
592
+ )
593
+
594
+ # No explicit SA configured — try ADC. This is the Cloud Run / GCE
595
+ # path; google-auth picks up the workload identity automatically.
596
+ try:
597
+ import google.auth as google_auth
598
+ except ImportError:
599
+ google_auth = None # type: ignore[assignment]
600
+ if google_auth is None:
601
+ raise ValueError(
602
+ "No Service Account credentials configured. Set "
603
+ "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS, "
604
+ "or install google-auth to use Application Default Credentials."
605
+ )
606
+ try:
607
+ credentials, _project = google_auth.default(scopes=_CHAT_SCOPES)
608
+ except Exception as exc:
609
+ raise ValueError(
610
+ "No Service Account credentials configured and Application "
611
+ "Default Credentials are unavailable. Set "
612
+ "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or run "
613
+ "``gcloud auth application-default login``. "
614
+ f"ADC error: {exc}"
615
+ ) from exc
616
+ logger.info(
617
+ "[GoogleChat] No SA JSON configured; using Application "
618
+ "Default Credentials"
619
+ )
620
+ return credentials
621
+
622
+ def _validate_config(self) -> Tuple[str, str]:
623
+ """Return (project_id, subscription_path) after validation.
624
+
625
+ Raises ValueError with a sanitized message on any config problem.
626
+ """
627
+ project_id = self.config.extra.get("project_id")
628
+ subscription = self.config.extra.get("subscription_name")
629
+ if not project_id:
630
+ raise ValueError(
631
+ "GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) is not set."
632
+ )
633
+ if not subscription:
634
+ raise ValueError(
635
+ "GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION) is not set."
636
+ )
637
+ match = _SUBSCRIPTION_PATH_RE.match(subscription)
638
+ if not match:
639
+ raise ValueError(
640
+ "GOOGLE_CHAT_SUBSCRIPTION_NAME must match "
641
+ "'projects/<project>/subscriptions/<sub>'."
642
+ )
643
+ if match.group("project") != project_id:
644
+ raise ValueError(
645
+ "project_id in GOOGLE_CHAT_PROJECT_ID does not match the "
646
+ "project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME."
647
+ )
648
+ return project_id, subscription
649
+
650
+ # ------------------------------------------------------------------
651
+ # Loop bridge helpers (thread -> asyncio loop)
652
+ # ------------------------------------------------------------------
653
+ @staticmethod
654
+ def _log_background_failure(future: Any) -> None:
655
+ try:
656
+ future.result()
657
+ except Exception:
658
+ logger.exception("[GoogleChat] Background inbound processing failed")
659
+
660
+ @staticmethod
661
+ def _loop_accepts_callbacks(loop: Optional[asyncio.AbstractEventLoop]) -> bool:
662
+ return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
663
+
664
+ def _submit_on_loop(self, coro: Any) -> None:
665
+ """Schedule a coroutine on the adapter loop from a Pub/Sub callback thread."""
666
+ loop = self._loop
667
+ if not self._loop_accepts_callbacks(loop):
668
+ # Loop already closed (shutdown race). Safe to drop; Pub/Sub will
669
+ # redeliver on next reconnect.
670
+ logger.warning("[GoogleChat] Loop not accepting callbacks; dropping event")
671
+ return
672
+ try:
673
+ from agent.async_utils import safe_schedule_threadsafe
674
+ future = safe_schedule_threadsafe(
675
+ coro, loop,
676
+ logger=logger,
677
+ log_message="[GoogleChat] Failed to schedule background callback",
678
+ log_level=logging.WARNING,
679
+ )
680
+ except RuntimeError:
681
+ logger.warning("[GoogleChat] Loop closed between check and submit")
682
+ return
683
+ if future is None:
684
+ return
685
+ future.add_done_callback(self._log_background_failure)
686
+
687
+ # ------------------------------------------------------------------
688
+ # Bot identity resolution
689
+ # ------------------------------------------------------------------
690
+ def _bot_id_cache_path(self) -> _Path:
691
+ """Location where the resolved bot user_id is cached across restarts."""
692
+ base = os.getenv("HERMES_HOME", str(_Path.home() / ".hermes"))
693
+ return _Path(base) / "google_chat_bot_id.json"
694
+
695
+ def _load_cached_bot_id(self) -> Optional[str]:
696
+ path = self._bot_id_cache_path()
697
+ if not path.exists():
698
+ return None
699
+ try:
700
+ data = json.loads(path.read_text(encoding="utf-8"))
701
+ return data.get("bot_user_id") or None
702
+ except (OSError, json.JSONDecodeError):
703
+ return None
704
+
705
+ def _save_cached_bot_id(self, bot_user_id: str) -> None:
706
+ try:
707
+ path = self._bot_id_cache_path()
708
+ path.parent.mkdir(parents=True, exist_ok=True)
709
+ path.write_text(
710
+ json.dumps({"bot_user_id": bot_user_id}),
711
+ encoding="utf-8",
712
+ )
713
+ except OSError:
714
+ logger.debug("[GoogleChat] Could not persist bot_user_id cache", exc_info=True)
715
+
716
+ async def _resolve_bot_user_id(self) -> Optional[str]:
717
+ """Resolve ``users/{id}`` via Chat API members.list on a known space.
718
+
719
+ Tries the home channel first, then any space from the allowlist.
720
+ If no space is known, returns None and self-filter falls back to
721
+ filtering ``sender.type == 'BOT'`` (which is still safe but less
722
+ precise — own messages and other bots look alike).
723
+ """
724
+ candidate_spaces: List[str] = []
725
+ if self.config.home_channel and self.config.home_channel.chat_id:
726
+ candidate_spaces.append(self.config.home_channel.chat_id)
727
+ # Env-configured allowed spaces (comma-separated). Optional.
728
+ extra_spaces = os.getenv("GOOGLE_CHAT_BOOTSTRAP_SPACES", "").strip()
729
+ if extra_spaces:
730
+ candidate_spaces.extend(
731
+ s.strip() for s in extra_spaces.split(",") if s.strip()
732
+ )
733
+ for space in candidate_spaces:
734
+ try:
735
+ members = await asyncio.to_thread(
736
+ lambda s=space: self._chat_api.spaces()
737
+ .members()
738
+ .list(parent=s, pageSize=50)
739
+ .execute(http=self._new_authed_http())
740
+ )
741
+ except HttpError as exc:
742
+ logger.debug(
743
+ "[GoogleChat] members.list failed on %s: %s",
744
+ space,
745
+ _redact_sensitive(str(exc)),
746
+ )
747
+ continue
748
+ for member in members.get("memberships", []):
749
+ if member.get("member", {}).get("type") == "BOT":
750
+ name = member.get("member", {}).get("name")
751
+ if name:
752
+ return name
753
+ return None
754
+
755
+ # ------------------------------------------------------------------
756
+ # Connection lifecycle
757
+ # ------------------------------------------------------------------
758
+ async def connect(self) -> bool:
759
+ """Validate config, authenticate, start Pub/Sub pull, resolve bot id."""
760
+ # First call into the heavy google-cloud stack — trigger the lazy
761
+ # import. ``_load_google_modules()`` is idempotent and rebinds the
762
+ # module globals (``pubsub_v1``, ``service_account``, ``HttpError``,
763
+ # …) used throughout this file. Anything that runs *before* this
764
+ # call would see the placeholders, so connect() is the natural
765
+ # gate.
766
+ if not _load_google_modules():
767
+ self._set_fatal_error(
768
+ code="missing_deps",
769
+ message="google-cloud-pubsub / google-api-python-client not installed",
770
+ retryable=False,
771
+ )
772
+ return False
773
+
774
+ self._loop = asyncio.get_running_loop()
775
+ try:
776
+ project_id, subscription_path = self._validate_config()
777
+ credentials = self._load_sa_credentials()
778
+ except (ValueError, FileNotFoundError) as exc:
779
+ msg = _redact_sensitive(str(exc))
780
+ logger.error("[GoogleChat] Config validation failed: %s", msg)
781
+ self._set_fatal_error(code="config_invalid", message=msg, retryable=False)
782
+ return False
783
+
784
+ self._project_id = project_id
785
+ self._subscription_path = subscription_path
786
+ self._credentials = credentials
787
+
788
+ # Build Chat REST client (sync; wrap calls in asyncio.to_thread).
789
+ try:
790
+ self._chat_api = await asyncio.to_thread(
791
+ lambda: build_service(
792
+ "chat",
793
+ "v1",
794
+ credentials=credentials,
795
+ cache_discovery=False,
796
+ )
797
+ )
798
+ except Exception as exc:
799
+ msg = _redact_sensitive(str(exc))
800
+ logger.error("[GoogleChat] Failed to build Chat API client: %s", msg)
801
+ self._set_fatal_error(code="chat_api_init", message=msg, retryable=False)
802
+ return False
803
+
804
+ # Attempt to load LEGACY single-user OAuth credentials at startup.
805
+ # In multi-user mode each user's token is loaded lazily by
806
+ # ``_load_per_user_chat_api`` on first send. The legacy slot is
807
+ # kept as a last-ditch fallback for pre-multi-user installs and
808
+ # for groups where the asker has no per-user token yet. Failure
809
+ # here is NON-fatal: text messaging continues to work; only
810
+ # attachments degrade to a setup-instructions text notice.
811
+ try:
812
+ from .oauth import (
813
+ load_user_credentials as _load_user_creds,
814
+ build_user_chat_service as _build_user_chat,
815
+ list_authorized_emails as _list_emails,
816
+ )
817
+ user_creds = await asyncio.to_thread(_load_user_creds)
818
+ if user_creds is not None:
819
+ self._user_credentials = user_creds
820
+ self._user_chat_api = await asyncio.to_thread(
821
+ lambda: _build_user_chat(user_creds)
822
+ )
823
+ logger.info(
824
+ "[GoogleChat] Legacy user OAuth loaded — fallback "
825
+ "attachment delivery enabled"
826
+ )
827
+ authorized = await asyncio.to_thread(_list_emails)
828
+ if authorized:
829
+ logger.info(
830
+ "[GoogleChat] %d per-user OAuth tokens on disk: %s",
831
+ len(authorized), ", ".join(authorized),
832
+ )
833
+ elif user_creds is None:
834
+ logger.info(
835
+ "[GoogleChat] No user OAuth tokens at setup — file "
836
+ "attachments will degrade to text-only fallback. "
837
+ "Each user runs /setup-files once in their own DM "
838
+ "to enable native attachments."
839
+ )
840
+ except Exception as exc:
841
+ logger.warning(
842
+ "[GoogleChat] User OAuth load failed (attachments will "
843
+ "degrade to text-only fallback): %s",
844
+ _redact_sensitive(str(exc)),
845
+ )
846
+ self._user_credentials = None
847
+ self._user_chat_api = None
848
+
849
+ # Load the persistent thread-count store so the side-thread
850
+ # heuristic in _build_message_event survives gateway restarts.
851
+ try:
852
+ await asyncio.to_thread(self._thread_count_store.load)
853
+ except Exception:
854
+ logger.warning(
855
+ "[GoogleChat] thread-count store load failed (treating "
856
+ "all threads as fresh)", exc_info=True,
857
+ )
858
+
859
+ # Sanity check: subscription exists / SA has access.
860
+ self._subscriber = pubsub_v1.SubscriberClient(credentials=credentials)
861
+ try:
862
+ await asyncio.to_thread(
863
+ lambda: self._subscriber.get_subscription(
864
+ request={"subscription": subscription_path}
865
+ )
866
+ )
867
+ except gax_exceptions.NotFound:
868
+ self._set_fatal_error(
869
+ code="subscription_not_found",
870
+ message="Pub/Sub subscription not found at configured path",
871
+ retryable=False,
872
+ )
873
+ return False
874
+ except gax_exceptions.PermissionDenied:
875
+ self._set_fatal_error(
876
+ code="subscription_permission",
877
+ message=(
878
+ "Service Account lacks roles/pubsub.subscriber on the "
879
+ "subscription"
880
+ ),
881
+ retryable=False,
882
+ )
883
+ return False
884
+ except Exception as exc:
885
+ msg = _redact_sensitive(str(exc))
886
+ logger.error("[GoogleChat] subscription.get failed: %s", msg)
887
+ self._set_fatal_error(code="subscription_check", message=msg, retryable=True)
888
+ return False
889
+
890
+ # Resolve bot user_id (eager): cache first, then members.list.
891
+ self._bot_user_id = self._load_cached_bot_id()
892
+ if not self._bot_user_id:
893
+ self._bot_user_id = await self._resolve_bot_user_id()
894
+ if self._bot_user_id:
895
+ self._save_cached_bot_id(self._bot_user_id)
896
+ else:
897
+ logger.info(
898
+ "[GoogleChat] bot_user_id not yet resolved; "
899
+ "will resolve on first addedToSpace or member lookup"
900
+ )
901
+
902
+ # Start the supervisor task that runs the Pub/Sub pull with exponential
903
+ # backoff + jitter on transient errors, bails out after N retries.
904
+ self._supervisor_task = asyncio.create_task(self._run_supervisor())
905
+ self._mark_connected()
906
+ logger.info(
907
+ "[GoogleChat] Connected; project=%s, subscription=<redacted>, "
908
+ "bot_user_id=%s, flow_control(msgs=%s, bytes=%s)",
909
+ project_id,
910
+ self._bot_user_id or "<unresolved>",
911
+ self._max_messages,
912
+ self._max_bytes,
913
+ )
914
+ return True
915
+
916
+ async def disconnect(self) -> None:
917
+ """Clean shutdown: stop accepting new messages, wait in-flight, close clients."""
918
+ self._shutting_down = True
919
+ if self._supervisor_task and not self._supervisor_task.done():
920
+ self._supervisor_task.cancel()
921
+ try:
922
+ await asyncio.wait_for(self._supervisor_task, timeout=5.0)
923
+ except (asyncio.CancelledError, asyncio.TimeoutError):
924
+ pass
925
+ if self._streaming_pull_future is not None:
926
+ try:
927
+ self._streaming_pull_future.cancel()
928
+ await asyncio.to_thread(self._streaming_pull_future.result, 10.0)
929
+ except Exception:
930
+ pass
931
+ self._streaming_pull_future = None
932
+ if self._subscriber is not None:
933
+ try:
934
+ await asyncio.to_thread(self._subscriber.close)
935
+ except Exception:
936
+ pass
937
+ self._subscriber = None
938
+ self._mark_disconnected()
939
+ logger.info("[GoogleChat] Disconnected")
940
+
941
+ # ------------------------------------------------------------------
942
+ # Pub/Sub supervisor (reconnect loop)
943
+ # ------------------------------------------------------------------
944
+ async def _run_supervisor(self) -> None:
945
+ """Run the streaming_pull with exponential backoff; fatal after 10 attempts.
946
+
947
+ ``subscribe()`` returns a concurrent.futures.Future that resolves when
948
+ the stream dies. We await ``future.result()`` in a worker thread and
949
+ react to exceptions.
950
+ """
951
+ attempt = 0
952
+ while not self._shutting_down:
953
+ flow = pubsub_v1.types.FlowControl(
954
+ max_messages=self._max_messages,
955
+ max_bytes=self._max_bytes,
956
+ )
957
+ try:
958
+ future = self._subscriber.subscribe(
959
+ self._subscription_path,
960
+ callback=self._on_pubsub_message,
961
+ flow_control=flow,
962
+ )
963
+ self._streaming_pull_future = future
964
+ if attempt > 0:
965
+ logger.info("[GoogleChat] Pub/Sub stream reconnected after %d attempts", attempt)
966
+ attempt = 0
967
+ # Blocks until stream dies or cancel().
968
+ await asyncio.to_thread(future.result)
969
+ # Normal completion = disconnect requested.
970
+ if self._shutting_down:
971
+ return
972
+ except asyncio.CancelledError:
973
+ return
974
+ except gax_exceptions.Unauthenticated:
975
+ self._set_fatal_error(
976
+ code="pubsub_auth",
977
+ message="Pub/Sub authentication failed (SA key invalid/revoked)",
978
+ retryable=False,
979
+ )
980
+ return
981
+ except gax_exceptions.PermissionDenied:
982
+ self._set_fatal_error(
983
+ code="pubsub_permission",
984
+ message="SA lacks pubsub.subscriber on the subscription",
985
+ retryable=False,
986
+ )
987
+ return
988
+ except Exception as exc:
989
+ attempt += 1
990
+ msg = _redact_sensitive(str(exc))
991
+ logger.warning(
992
+ "[GoogleChat] Pub/Sub stream died (attempt %d/%d): %s",
993
+ attempt,
994
+ self._MAX_RECONNECT_ATTEMPTS,
995
+ msg,
996
+ )
997
+ if attempt >= self._MAX_RECONNECT_ATTEMPTS:
998
+ self._set_fatal_error(
999
+ code="pubsub_reconnect_exhausted",
1000
+ message=f"Pub/Sub reconnect failed {attempt} times; giving up",
1001
+ retryable=False,
1002
+ )
1003
+ return
1004
+ delay = min(
1005
+ self._RECONNECT_MAX_DELAY,
1006
+ self._RECONNECT_BASE_DELAY * (2 ** (attempt - 1)),
1007
+ )
1008
+ # Full jitter: pick uniformly in [0, delay].
1009
+ sleep_for = random.uniform(0, delay)
1010
+ try:
1011
+ await asyncio.sleep(sleep_for)
1012
+ except asyncio.CancelledError:
1013
+ return
1014
+
1015
+ # ------------------------------------------------------------------
1016
+ # Inbound event handling (Pub/Sub callback runs in a thread)
1017
+ # ------------------------------------------------------------------
1018
+ @staticmethod
1019
+ def _extract_message_payload(
1020
+ envelope: Dict[str, Any], ce_type: str = ""
1021
+ ) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]:
1022
+ """Detect Pub/Sub envelope format and return ``(message, space, format_name)``.
1023
+
1024
+ Three known formats are accepted. Returns ``None`` when the envelope
1025
+ is unrecognized, is a non-MESSAGE event, or otherwise should be
1026
+ silently dropped.
1027
+
1028
+ Format 1 — Workspace Add-ons (canonical, ce-type-driven)::
1029
+
1030
+ {"chat": {"messagePayload": {"message": {...}, "space": {...}}}}
1031
+
1032
+ Format 2 — Native Chat API Pub/Sub (alternative configuration where
1033
+ the Chat app publishes events directly without the Workspace
1034
+ Add-ons wrapper)::
1035
+
1036
+ {"type": "MESSAGE", "message": {...}, "space": {...}}
1037
+
1038
+ Format 3 — Relay / flat (a custom Cloud Run relay that flattens the
1039
+ Chat event into top-level fields)::
1040
+
1041
+ {"event_type": "MESSAGE", "sender_email": "...", "text": "...",
1042
+ "space_name": "spaces/X", "thread_name": "spaces/X/threads/Y",
1043
+ "message_name": "spaces/X/messages/M.M"}
1044
+
1045
+ For format 3 the helper synthesizes a Chat-API-shaped ``message``
1046
+ dict so downstream code (``_dispatch_message`` →
1047
+ ``_build_message_event``) can consume it without branching.
1048
+ """
1049
+ # Format 1: Workspace Add-ons. The chat block carries one of
1050
+ # messagePayload / membershipPayload / cardClickedPayload depending
1051
+ # on the ce-type. ``_on_pubsub_message`` handles the membership and
1052
+ # card branches before reaching this helper, so here we only accept
1053
+ # message payloads.
1054
+ chat_block = envelope.get("chat") or {}
1055
+ msg_payload_wrapper = chat_block.get("messagePayload") if chat_block else None
1056
+ if msg_payload_wrapper:
1057
+ msg = msg_payload_wrapper.get("message") or {}
1058
+ space = msg_payload_wrapper.get("space") or msg.get("space") or {}
1059
+ return msg, space, "workspace_addons"
1060
+
1061
+ # Format 2: Native Chat API Pub/Sub. Detected by a top-level
1062
+ # ``message`` object plus a ``type`` field; only MESSAGE events
1063
+ # flow through here.
1064
+ if isinstance(envelope.get("message"), dict):
1065
+ if envelope.get("type", "") != "MESSAGE":
1066
+ return None
1067
+ msg = envelope["message"]
1068
+ space = envelope.get("space") or msg.get("space") or {}
1069
+ return msg, space, "native_chat_api"
1070
+
1071
+ # Format 3: Relay / flat. A custom Cloud Run relay typically
1072
+ # forwards Chat events with this shape so the bot can run without
1073
+ # direct GCP credentials.
1074
+ if "event_type" in envelope or "sender_email" in envelope:
1075
+ if envelope.get("event_type", "MESSAGE") != "MESSAGE":
1076
+ return None
1077
+ sender_email = (envelope.get("sender_email") or "").strip()
1078
+ sender_display = (
1079
+ envelope.get("sender_display_name")
1080
+ or sender_email
1081
+ or "Unknown"
1082
+ )
1083
+ # The Chat resource name is unknown for relay events; synthesize
1084
+ # a stable surrogate from the sender email so dedup keys and
1085
+ # session IDs stay deterministic across redelivery.
1086
+ sender_name_surrogate = (
1087
+ "users/relay-"
1088
+ + (sender_email or "unknown").replace("@", "_at_").replace(".", "_")
1089
+ )
1090
+ text = envelope.get("text", "") or ""
1091
+ # Honor the relay's declared sender_type when present so the
1092
+ # downstream BOT self-filter (sender_type == "BOT") fires for
1093
+ # bot-originated messages forwarded by the relay. Hardcoding
1094
+ # "HUMAN" here meant the bot would re-process its own replies
1095
+ # if the relay forwarded them, and allowed a relay envelope to
1096
+ # impersonate any allowlisted user without ever being marked
1097
+ # as a bot. Default to "HUMAN" for backward compatibility when
1098
+ # the relay does not provide the field.
1099
+ #
1100
+ # Operator contract: the relay MUST forward sender.type from
1101
+ # the upstream Chat event as ``sender_type``. Relays that
1102
+ # forward bot replies as HUMAN (or omit the field) cannot be
1103
+ # distinguished from genuine humans here.
1104
+ sender_type_raw = (envelope.get("sender_type") or "HUMAN")
1105
+ sender_type = str(sender_type_raw).strip().upper() or "HUMAN"
1106
+ if sender_type not in {"HUMAN", "BOT"}:
1107
+ sender_type = "HUMAN"
1108
+ msg: Dict[str, Any] = {
1109
+ "name": envelope.get("message_name", "") or "",
1110
+ "sender": {
1111
+ "name": sender_name_surrogate,
1112
+ "email": sender_email,
1113
+ "displayName": sender_display,
1114
+ "type": sender_type,
1115
+ },
1116
+ "text": text,
1117
+ "argumentText": text,
1118
+ }
1119
+ thread_name = envelope.get("thread_name") or ""
1120
+ if thread_name:
1121
+ msg["thread"] = {"name": thread_name}
1122
+ space = {
1123
+ "name": envelope.get("space_name", "") or "",
1124
+ "spaceType": envelope.get("space_type", "SPACE"),
1125
+ }
1126
+ return msg, space, "relay_flat"
1127
+
1128
+ return None
1129
+
1130
+ def _on_pubsub_message(self, message: Any) -> None:
1131
+ """Pub/Sub callback — parse envelope and dispatch to asyncio loop.
1132
+
1133
+ Runs in a Pub/Sub SubscriberClient worker thread, NOT the event loop.
1134
+ Never block this function; never raise out of it (that triggers
1135
+ Pub/Sub nack + infinite redelivery).
1136
+
1137
+ Google Chat Events API uses CloudEvents-style Pub/Sub messages. The
1138
+ event type is carried in Pub/Sub message attributes (``ce-type``),
1139
+ not in the JSON body. The body is wrapped in a ``chat`` object whose
1140
+ keys depend on the event type:
1141
+
1142
+ - google.workspace.chat.message.v1.created
1143
+ -> envelope["chat"]["messagePayload"] = {space, message}
1144
+ - google.workspace.chat.membership.v1.created
1145
+ -> envelope["chat"]["membershipPayload"] = {space, membership}
1146
+ - google.workspace.chat.membership.v1.deleted
1147
+ -> envelope["chat"]["membershipPayload"] = {space, membership}
1148
+ """
1149
+ if self._shutting_down:
1150
+ message.nack()
1151
+ return
1152
+ try:
1153
+ envelope = json.loads(message.data.decode("utf-8"))
1154
+ except Exception:
1155
+ logger.exception("[GoogleChat] Could not parse Pub/Sub envelope")
1156
+ message.ack()
1157
+ return
1158
+
1159
+ attrs = dict(getattr(message, "attributes", {}) or {})
1160
+ ce_type = attrs.get("ce-type") or ""
1161
+ logger.debug(
1162
+ "[GoogleChat] Envelope keys=%s, ce-type=%s",
1163
+ list(envelope.keys()),
1164
+ ce_type,
1165
+ )
1166
+ if os.getenv("GOOGLE_CHAT_DEBUG_RAW"):
1167
+ # Dangerous flag: contains message text and sender email. Route
1168
+ # through the global redaction filter and gate at DEBUG level so
1169
+ # default log configurations never surface it. Operators must
1170
+ # enable DEBUG logging AND set this env var to see the dump.
1171
+ try:
1172
+ from agent.redact import redact_sensitive_text
1173
+
1174
+ dump = redact_sensitive_text(json.dumps(envelope))
1175
+ except Exception:
1176
+ dump = "<redact filter unavailable>"
1177
+ logger.debug("[GoogleChat] RAW envelope (redacted): %s", dump[:2000])
1178
+
1179
+ try:
1180
+ chat_block = envelope.get("chat") or {}
1181
+
1182
+ # --- Membership events ---
1183
+ if "membership" in ce_type or "MEMBERSHIP" in ce_type:
1184
+ mpl = chat_block.get("membershipPayload") or {}
1185
+ space = mpl.get("space") or {}
1186
+ membership = mpl.get("membership") or {}
1187
+ if "created" in ce_type:
1188
+ # ADDED_TO_SPACE for this bot — resolve self user_id.
1189
+ member = membership.get("member") or {}
1190
+ if member.get("type") == "BOT" and not self._bot_user_id:
1191
+ name = member.get("name")
1192
+ if name:
1193
+ self._bot_user_id = name
1194
+ self._save_cached_bot_id(name)
1195
+ logger.info(
1196
+ "[GoogleChat] ADDED_TO_SPACE %s", space.get("name", "?")
1197
+ )
1198
+ else:
1199
+ logger.info(
1200
+ "[GoogleChat] REMOVED_FROM_SPACE %s", space.get("name", "?")
1201
+ )
1202
+ message.ack()
1203
+ return
1204
+
1205
+ # --- Card-click events (v2 follow-up) ---
1206
+ if "widget" in ce_type or "card" in ce_type.lower():
1207
+ logger.info(
1208
+ "[GoogleChat] Card/widget event ack'd (v2 feature, deferred)"
1209
+ )
1210
+ message.ack()
1211
+ return
1212
+
1213
+ # --- Message events ---
1214
+ extracted = self._extract_message_payload(envelope, ce_type)
1215
+ if extracted is None:
1216
+ logger.debug(
1217
+ "[GoogleChat] Envelope did not match a known message format; "
1218
+ "ce-type=%s, keys=%s", ce_type, list(envelope.keys())
1219
+ )
1220
+ message.ack()
1221
+ return
1222
+
1223
+ msg, space, _fmt = extracted
1224
+ sender = msg.get("sender") or {}
1225
+ sender_type = sender.get("type") or ""
1226
+
1227
+ # Self-filter: drop bot-sourced messages (own replies and other bots).
1228
+ if sender_type == "BOT":
1229
+ message.ack()
1230
+ return
1231
+
1232
+ # Dedup guard — Pub/Sub is at-least-once.
1233
+ msg_name = msg.get("name") or ""
1234
+ if msg_name and self._dedup.is_duplicate(msg_name):
1235
+ logger.debug("[GoogleChat] Dedup drop for %s", msg_name)
1236
+ message.ack()
1237
+ return
1238
+
1239
+ # Wrap msg with parent-level space so _build_message_event can find it.
1240
+ msg_with_space = dict(msg)
1241
+ if "space" not in msg_with_space and space:
1242
+ msg_with_space["space"] = space
1243
+
1244
+ # Enrich envelope with a synthetic top-level "space" field so the
1245
+ # dispatch side has a consistent shape regardless of format.
1246
+ enriched_env = dict(envelope)
1247
+ if "space" not in enriched_env and space:
1248
+ enriched_env["space"] = space
1249
+
1250
+ self._submit_on_loop(self._dispatch_message(msg_with_space, enriched_env))
1251
+ message.ack()
1252
+ except Exception:
1253
+ logger.exception("[GoogleChat] Error in _on_pubsub_message")
1254
+ try:
1255
+ message.ack()
1256
+ except Exception:
1257
+ pass
1258
+
1259
+ async def _dispatch_message(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> None:
1260
+ """Translate a Chat message payload to a MessageEvent and hand off.
1261
+
1262
+ Intercepts the ``/setup-files`` admin command BEFORE the agent
1263
+ sees it — that's a bot-local OAuth setup flow, not a prompt.
1264
+ Everything else flows to ``handle_message`` as normal.
1265
+ """
1266
+ try:
1267
+ event = await self._build_message_event(msg, envelope)
1268
+ if event is None:
1269
+ return
1270
+
1271
+ # Short-circuit /setup-files before the agent dispatch.
1272
+ text = (event.text or "").strip()
1273
+ if text.startswith("/setup-files") and event.source is not None:
1274
+ # The sender's email (user_id_alt) is the per-user OAuth
1275
+ # key — the bot stores this user's token at
1276
+ # ${HERMES_HOME}/google_chat_user_tokens/<sanitized>.json
1277
+ # so when User B asks for a file later in B's DM, B's
1278
+ # token gets used (not the first person who set up files).
1279
+ sender_email = (
1280
+ event.source.user_id_alt
1281
+ if event.source and event.source.user_id_alt
1282
+ else None
1283
+ )
1284
+ handled = await self._handle_setup_files_command(
1285
+ chat_id=event.source.chat_id,
1286
+ thread_id=event.source.thread_id,
1287
+ raw_text=text,
1288
+ sender_email=sender_email,
1289
+ )
1290
+ if handled:
1291
+ return
1292
+
1293
+ await self.handle_message(event)
1294
+ except Exception:
1295
+ logger.exception("[GoogleChat] _dispatch_message failed")
1296
+
1297
+ async def _handle_setup_files_command(
1298
+ self,
1299
+ chat_id: str,
1300
+ thread_id: Optional[str],
1301
+ raw_text: str,
1302
+ sender_email: Optional[str] = None,
1303
+ ) -> bool:
1304
+ """Run the in-chat OAuth setup flow for native attachment delivery.
1305
+
1306
+ Returns ``True`` if the message was consumed (no agent dispatch),
1307
+ ``False`` if it should fall through.
1308
+
1309
+ Multi-user mode: ``sender_email`` is the asker's identity, which
1310
+ is also the per-user OAuth key. ``status`` / ``start`` / ``revoke``
1311
+ / code-exchange all operate on THIS user's token slot. When
1312
+ ``sender_email`` is ``None`` (e.g. tests, or older inbound events
1313
+ without a populated email field) the handler falls back to the
1314
+ legacy single-user path so pre-multi-user installs keep working.
1315
+
1316
+ Subcommands:
1317
+ /setup-files → show status + next step
1318
+ /setup-files start → print OAuth URL
1319
+ /setup-files revoke → revoke and delete stored token
1320
+ /setup-files <CODE_OR_URL> → exchange auth code for token
1321
+
1322
+ Pre-requisite: client_secret.json must already be on the host
1323
+ (one-time terminal step). The status reply tells the user how to
1324
+ do that if it's missing.
1325
+ """
1326
+ from . import oauth as oauth_helper
1327
+
1328
+ # Normalize the email: lowercase + strip. The on-disk token path
1329
+ # is sanitized further inside the helper, but having the same
1330
+ # normalization at both ends keeps cache lookups consistent.
1331
+ sender_key = sender_email.strip().lower() if sender_email else None
1332
+
1333
+ parts = raw_text.split(maxsplit=1)
1334
+ # parts[0] is "/setup-files"; parts[1..] is the optional argument
1335
+ arg = parts[1].strip() if len(parts) > 1 else ""
1336
+
1337
+ async def _reply(text: str) -> None:
1338
+ body: Dict[str, Any] = {"text": text}
1339
+ if thread_id:
1340
+ body["thread"] = {"name": thread_id}
1341
+ try:
1342
+ await self._create_message(chat_id, body)
1343
+ except Exception:
1344
+ logger.debug(
1345
+ "[GoogleChat] /setup-files reply send failed",
1346
+ exc_info=True,
1347
+ )
1348
+
1349
+ # Status / no-arg: show what's set up and what to do next.
1350
+ if not arg:
1351
+ client_secret_present = (
1352
+ oauth_helper._client_secret_path().exists()
1353
+ )
1354
+ token_path = oauth_helper._token_path(sender_key)
1355
+ token_present = token_path.exists()
1356
+ creds = (
1357
+ oauth_helper.load_user_credentials(sender_key)
1358
+ if token_present else None
1359
+ )
1360
+ if creds is not None:
1361
+ who = sender_key or "shared (legacy)"
1362
+ await _reply(
1363
+ "✅ Native attachment delivery is **active** for "
1364
+ f"`{who}`.\n"
1365
+ f"Token: `{token_path}`\n"
1366
+ "Send `/setup-files revoke` to disable."
1367
+ )
1368
+ return True
1369
+ if not client_secret_present:
1370
+ await _reply(
1371
+ "🔧 Native attachment delivery is **not configured**.\n"
1372
+ "**Step 1 (one-time, on the host):** create OAuth client "
1373
+ "credentials at "
1374
+ "https://console.cloud.google.com/apis/credentials → "
1375
+ "*Create credentials* → *OAuth client ID* → *Desktop app*. "
1376
+ "Download the JSON. Then on the host run:\n"
1377
+ "```\n"
1378
+ "python -m plugins.platforms.google_chat.oauth "
1379
+ "--client-secret /path/to/client_secret.json\n"
1380
+ "```\n"
1381
+ "**Step 2:** come back here and send `/setup-files start`."
1382
+ )
1383
+ return True
1384
+ await _reply(
1385
+ "🔧 Client credentials are stored but you haven't "
1386
+ "authorized yet. Send `/setup-files start` to begin."
1387
+ )
1388
+ return True
1389
+
1390
+ if arg == "start":
1391
+ if not oauth_helper._client_secret_path().exists():
1392
+ await _reply(
1393
+ "⚠️ No client credentials stored on the host. Send "
1394
+ "`/setup-files` (no args) for setup instructions."
1395
+ )
1396
+ return True
1397
+ try:
1398
+ # Reuse the helper logic but capture stdout via a sync
1399
+ # thread so we don't print to the gateway terminal.
1400
+ import io
1401
+ import contextlib
1402
+ buf = io.StringIO()
1403
+ with contextlib.redirect_stdout(buf):
1404
+ await asyncio.to_thread(
1405
+ oauth_helper.get_auth_url, sender_key,
1406
+ )
1407
+ auth_url = buf.getvalue().strip().splitlines()[-1]
1408
+ except SystemExit:
1409
+ await _reply(
1410
+ "❌ Couldn't generate the OAuth URL. Check the gateway "
1411
+ "logs and verify the client_secret.json is valid."
1412
+ )
1413
+ return True
1414
+ except Exception as exc:
1415
+ logger.warning(
1416
+ "[GoogleChat] /setup-files start failed: %s", exc,
1417
+ )
1418
+ await _reply(f"❌ Error: {exc}")
1419
+ return True
1420
+ await _reply(
1421
+ "1. Open this URL in your browser and authorize:\n"
1422
+ f"{auth_url}\n\n"
1423
+ "2. After clicking *Allow*, your browser will fail to load "
1424
+ "`http://localhost:1/?...&code=...`. That's expected.\n\n"
1425
+ "3. Copy the entire failed URL from the browser's URL bar "
1426
+ "and paste it back here as: `/setup-files <PASTE_URL>` "
1427
+ "(or just the `code=...` value).\n\n"
1428
+ "Tip: the URL contains your access grant — keep it private."
1429
+ )
1430
+ return True
1431
+
1432
+ if arg == "revoke":
1433
+ try:
1434
+ import io
1435
+ import contextlib
1436
+ buf = io.StringIO()
1437
+ with contextlib.redirect_stdout(buf):
1438
+ await asyncio.to_thread(oauth_helper.revoke, sender_key)
1439
+ output = buf.getvalue().strip() or "Revoked."
1440
+ except SystemExit:
1441
+ output = "Revoke completed (some steps may have been skipped)."
1442
+ except Exception as exc:
1443
+ logger.warning(
1444
+ "[GoogleChat] /setup-files revoke failed: %s", exc,
1445
+ )
1446
+ await _reply(f"❌ Error revoking: {exc}")
1447
+ return True
1448
+ # Wipe in-memory creds so subsequent uploads fall through to
1449
+ # the setup-instructions text notice immediately. Scope the
1450
+ # eviction to the sender's slot — Bob revoking shouldn't
1451
+ # break Alice's per-user token nor wipe the shared legacy
1452
+ # fallback that other users may still depend on.
1453
+ if sender_key:
1454
+ self._user_creds_by_email.pop(sender_key, None)
1455
+ self._user_chat_api_by_email.pop(sender_key, None)
1456
+ else:
1457
+ self._user_credentials = None
1458
+ self._user_chat_api = None
1459
+ await _reply(f"✅ Done.\n```\n{output}\n```")
1460
+ return True
1461
+
1462
+ # Anything else is treated as the auth code or the failed-redirect
1463
+ # URL the user pasted.
1464
+ try:
1465
+ import io
1466
+ import contextlib
1467
+ buf = io.StringIO()
1468
+ with contextlib.redirect_stdout(buf):
1469
+ await asyncio.to_thread(
1470
+ oauth_helper.exchange_auth_code, arg, sender_key,
1471
+ )
1472
+ output = buf.getvalue().strip()
1473
+ except SystemExit:
1474
+ await _reply(
1475
+ "❌ Token exchange failed. The code may have expired or "
1476
+ "the URL is malformed. Send `/setup-files start` to get "
1477
+ "a fresh OAuth URL."
1478
+ )
1479
+ return True
1480
+ except Exception as exc:
1481
+ logger.warning(
1482
+ "[GoogleChat] /setup-files exchange failed: %s", exc,
1483
+ )
1484
+ await _reply(f"❌ Error: {exc}")
1485
+ return True
1486
+
1487
+ # Re-load credentials into the adapter so the next file send uses
1488
+ # them WITHOUT a gateway restart.
1489
+ try:
1490
+ new_creds = await asyncio.to_thread(
1491
+ oauth_helper.load_user_credentials, sender_key,
1492
+ )
1493
+ if new_creds is not None:
1494
+ new_api = await asyncio.to_thread(
1495
+ lambda: oauth_helper.build_user_chat_service(new_creds)
1496
+ )
1497
+ if sender_key:
1498
+ self._user_creds_by_email[sender_key] = new_creds
1499
+ self._user_chat_api_by_email[sender_key] = new_api
1500
+ else:
1501
+ self._user_credentials = new_creds
1502
+ self._user_chat_api = new_api
1503
+ await _reply(
1504
+ "✅ Authorized! Native attachment delivery is now "
1505
+ "active. Try asking me to send you a PDF."
1506
+ )
1507
+ return True
1508
+ except Exception as exc:
1509
+ logger.warning(
1510
+ "[GoogleChat] post-exchange creds load failed: %s", exc,
1511
+ )
1512
+
1513
+ await _reply(
1514
+ "⚠️ Token exchanged but the gateway couldn't load the new "
1515
+ "credentials in-memory. Restart the gateway and the token "
1516
+ f"at `{oauth_helper._token_path(sender_key)}` will be picked "
1517
+ f"up.\nHelper output:\n```\n{output}\n```"
1518
+ )
1519
+ return True
1520
+
1521
+ async def _build_message_event(
1522
+ self, msg: Dict[str, Any], envelope: Dict[str, Any]
1523
+ ) -> Optional[MessageEvent]:
1524
+ """Parse a Chat API message into a hermes MessageEvent."""
1525
+ space = envelope.get("space") or msg.get("space") or {}
1526
+ space_name = space.get("name") or "" # "spaces/XXX"
1527
+ space_type = (space.get("type") or space.get("spaceType") or "").upper()
1528
+ thread = msg.get("thread") or {}
1529
+ thread_name = thread.get("name") or None
1530
+ sender = msg.get("sender") or {}
1531
+ sender_name = sender.get("name") or ""
1532
+ sender_display = sender.get("displayName") or sender.get("email") or sender_name
1533
+ sender_email = sender.get("email") or ""
1534
+
1535
+ # Cache the asker's email per chat_id so _send_file can pick the
1536
+ # right per-user OAuth token when the agent later wants to send
1537
+ # an attachment in this conversation. Lower-cased so cache hits
1538
+ # match the sanitized token-file lookup.
1539
+ if sender_email and space_name:
1540
+ self._last_sender_by_chat[space_name] = sender_email.strip().lower()
1541
+
1542
+ chat_type = "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group"
1543
+ text = msg.get("argumentText") or msg.get("text") or ""
1544
+ text = text.strip()
1545
+
1546
+ # Slash command: emit MessageType.COMMAND with normalized text.
1547
+ slash = msg.get("slashCommand") or {}
1548
+ is_slash = bool(slash)
1549
+ if is_slash:
1550
+ command_id = str(slash.get("commandId") or "")
1551
+ if command_id and not text.startswith("/"):
1552
+ text = f"/cmd_{command_id} {text}".strip()
1553
+
1554
+ # Attachments: download and cache.
1555
+ media_urls: List[str] = []
1556
+ media_types: List[str] = []
1557
+ message_type = MessageType.TEXT
1558
+ attachments = msg.get("attachment") or []
1559
+ for att in attachments:
1560
+ try:
1561
+ local_path, mime = await self._download_attachment(att)
1562
+ except Exception:
1563
+ logger.exception("[GoogleChat] attachment download failed")
1564
+ continue
1565
+ if not local_path:
1566
+ continue
1567
+ media_urls.append(local_path)
1568
+ media_types.append(mime or "application/octet-stream")
1569
+ # Prefer the first-seen type for MessageType if no text present.
1570
+ if message_type == MessageType.TEXT and not text:
1571
+ message_type = _mime_for_message_type(mime or "")
1572
+
1573
+ if is_slash:
1574
+ message_type = MessageType.COMMAND
1575
+
1576
+ # Increment the persistent inbound count for this thread.
1577
+ # The PRE-increment value (==0 for the very first time we see
1578
+ # this thread, persisted across gateway restarts) drives the
1579
+ # main-flow-vs-side-thread heuristic below.
1580
+ prev_thread_count = 0
1581
+ if thread_name and space_name:
1582
+ prev_thread_count = self._thread_count_store.incr(
1583
+ space_name, thread_name
1584
+ )
1585
+
1586
+ # Session-thread + outbound-thread routing for DMs:
1587
+ # - prev_count == 0 → first message in this thread. Google Chat
1588
+ # creates a fresh thread per top-level message in the DM input
1589
+ # box; treat as "main flow" so all top-level messages share
1590
+ # one DM session and the user keeps continuity. The bot's
1591
+ # reply ALSO must NOT thread with the user message — if we
1592
+ # pass thread.name on outbound, Chat displays the pair as an
1593
+ # expandable thread under the user's message instead of two
1594
+ # adjacent top-level cards.
1595
+ # - prev_count >= 1 → user explicitly engaged a thread that
1596
+ # already had messages (clicked "Reply in thread" on a prior
1597
+ # message). Isolate session by chat_id+thread_id, AND keep
1598
+ # the bot's reply inside that thread.
1599
+ #
1600
+ # For groups, threads ARE meaningful conversational containers
1601
+ # (Telegram forum / Discord thread parity); always isolate AND
1602
+ # always reply in-thread.
1603
+ if chat_type == "dm":
1604
+ is_side_thread = prev_thread_count > 0
1605
+ session_thread_id = thread_name if is_side_thread else None
1606
+ # Outbound thread cache: populated only when side-thread, so
1607
+ # _resolve_thread_id falls through to "no thread" on main
1608
+ # flow and the bot reply lands as a top-level sibling.
1609
+ if thread_name and space_name and is_side_thread:
1610
+ self._last_inbound_thread[space_name] = thread_name
1611
+ elif space_name:
1612
+ self._last_inbound_thread.pop(space_name, None)
1613
+ else:
1614
+ session_thread_id = thread_name
1615
+ # Groups always reply in-thread.
1616
+ if thread_name and space_name:
1617
+ self._last_inbound_thread[space_name] = thread_name
1618
+
1619
+ source = self.build_source(
1620
+ chat_id=space_name,
1621
+ chat_name=space.get("displayName") or space.get("name") or "",
1622
+ chat_type=chat_type,
1623
+ # ``user_id`` is the canonical identity used by allowlists,
1624
+ # session keys, and audit. Operators configure
1625
+ # ``GOOGLE_CHAT_ALLOWED_USERS`` with email addresses (the
1626
+ # value Google Chat surfaces in its UI), so the email is
1627
+ # the natural canonical id. The Chat resource name
1628
+ # ``users/{id}`` moves to ``user_id_alt`` for traceability
1629
+ # and Chat-API operations that need it. Falls back to the
1630
+ # resource name when sender has no email (rare — bot-to-bot
1631
+ # or system events). Pattern lifted from PR #14965.
1632
+ user_id=(sender_email or sender_name),
1633
+ user_name=sender_display,
1634
+ thread_id=session_thread_id,
1635
+ user_id_alt=(sender_name or None),
1636
+ )
1637
+ return MessageEvent(
1638
+ text=text,
1639
+ message_type=message_type,
1640
+ source=source,
1641
+ raw_message=msg,
1642
+ message_id=msg.get("name") or None,
1643
+ media_urls=media_urls,
1644
+ media_types=media_types,
1645
+ )
1646
+
1647
+ async def _download_attachment(
1648
+ self, attachment: Dict[str, Any]
1649
+ ) -> Tuple[Optional[str], Optional[str]]:
1650
+ """Download an inbound attachment to the local cache; return (path, mime).
1651
+
1652
+ Priority for bot Service Accounts:
1653
+
1654
+ 1. ``attachmentDataRef.resourceName`` via ``chat.media.download`` —
1655
+ the supported bot path. The Service Account bearer token has
1656
+ ``chat.bot`` scope which the Chat API authorises against the
1657
+ space membership.
1658
+ 2. Drive-hosted files (``source == 'DRIVE_FILE'``) require user
1659
+ OAuth and Drive scope; skip with a log.
1660
+ 3. Direct HTTP fetch of ``downloadUri`` only as a last resort —
1661
+ that URL is meant for user OAuth tokens (chat.google.com
1662
+ returns 401 for SA bearer tokens) and is unlikely to work,
1663
+ but we keep the path for forward-compat with Google changes.
1664
+ """
1665
+ mime = attachment.get("contentType") or ""
1666
+ source = attachment.get("source") or ""
1667
+ name = attachment.get("name") or ""
1668
+ attachment_data_ref = attachment.get("attachmentDataRef") or {}
1669
+ resource_name = attachment_data_ref.get("resourceName") or ""
1670
+ download_uri = attachment.get("downloadUri") or ""
1671
+
1672
+ # NOTE on ``source == "DRIVE_FILE"``: Google Chat tags BOTH
1673
+ # drag-and-drop chat uploads AND Drive-picker shares with this
1674
+ # source string, but the two have different access models.
1675
+ # Drag-and-drop uploads come with an ``attachmentDataRef.resourceName``
1676
+ # that bot SA tokens CAN download via ``media.download_media``.
1677
+ # Pure Drive-picker shares often lack that field and require
1678
+ # user OAuth + Drive scope (which we deliberately don't request).
1679
+ # So we only short-circuit when there's nothing the bot path
1680
+ # can use — otherwise try the bot path first.
1681
+ if source == "DRIVE_FILE" and not resource_name:
1682
+ logger.info(
1683
+ "[GoogleChat] Skipping Drive-picker attachment (no "
1684
+ "resourceName, would need user-OAuth Drive scope)"
1685
+ )
1686
+ return None, mime
1687
+
1688
+ data: Optional[bytes] = None
1689
+
1690
+ # Path 1: media.download with attachmentDataRef.resourceName (bot-path).
1691
+ if resource_name:
1692
+ def _fetch_media() -> bytes:
1693
+ req = self._chat_api.media().download_media(
1694
+ resourceName=resource_name,
1695
+ )
1696
+ from googleapiclient.http import MediaIoBaseDownload
1697
+ import io
1698
+
1699
+ buf = io.BytesIO()
1700
+ downloader = MediaIoBaseDownload(buf, req)
1701
+ done = False
1702
+ while not done:
1703
+ _status, done = downloader.next_chunk()
1704
+ return buf.getvalue()
1705
+
1706
+ try:
1707
+ data = await asyncio.to_thread(_fetch_media)
1708
+ except HttpError as exc:
1709
+ logger.warning(
1710
+ "[GoogleChat] media.download_media failed: %s",
1711
+ _redact_sensitive(str(exc)),
1712
+ )
1713
+ data = None
1714
+
1715
+ # Path 2: downloadUri fallback (rarely works with SA tokens, but try).
1716
+ if data is None and download_uri:
1717
+ if not _is_google_owned_host(download_uri):
1718
+ logger.warning(
1719
+ "[GoogleChat] Rejecting attachment fetch: non-Google host"
1720
+ )
1721
+ return None, mime
1722
+
1723
+ def _fetch_uri() -> bytes:
1724
+ import google.auth.transport.requests as gar
1725
+
1726
+ authed_session = gar.AuthorizedSession(self._credentials)
1727
+ resp = authed_session.get(download_uri, timeout=30)
1728
+ resp.raise_for_status()
1729
+ return resp.content
1730
+
1731
+ try:
1732
+ data = await asyncio.to_thread(_fetch_uri)
1733
+ except Exception as exc:
1734
+ logger.warning(
1735
+ "[GoogleChat] downloadUri fetch failed (SA tokens often "
1736
+ "lack access here; this is expected for user-uploaded "
1737
+ "content): %s",
1738
+ _redact_sensitive(str(exc)),
1739
+ )
1740
+ return None, mime
1741
+
1742
+ if data is None:
1743
+ return None, mime
1744
+
1745
+ # Cache based on MIME. Upstream's cache_* helpers expect `ext` for
1746
+ # media (image/audio/video) and a positional `filename` for docs.
1747
+ filename = name.split("/")[-1] if name else "attachment"
1748
+ if "." in filename:
1749
+ ext = "." + filename.rsplit(".", 1)[-1].lower()
1750
+ else:
1751
+ ext = ""
1752
+ if mime.startswith("image/"):
1753
+ local = cache_image_from_bytes(data, ext=ext or ".jpg")
1754
+ elif mime.startswith("audio/"):
1755
+ local = cache_audio_from_bytes(data, ext=ext or ".ogg")
1756
+ elif mime.startswith("video/"):
1757
+ local = cache_video_from_bytes(data, ext=ext or ".mp4")
1758
+ else:
1759
+ local = cache_document_from_bytes(data, filename)
1760
+ return local, mime
1761
+
1762
+ # ------------------------------------------------------------------
1763
+ # Outbound send paths
1764
+ # ------------------------------------------------------------------
1765
+ async def send(
1766
+ self,
1767
+ chat_id: str,
1768
+ content: str,
1769
+ reply_to: Optional[str] = None,
1770
+ metadata: Optional[Dict[str, Any]] = None,
1771
+ ) -> SendResult:
1772
+ """Send a text message.
1773
+
1774
+ Signature matches ``BasePlatformAdapter.send``: ``content`` is the
1775
+ message body, ``reply_to`` is an optional message_id (the inbound
1776
+ message to thread under), and ``metadata`` may carry ``thread_id``
1777
+ (the resolved Google Chat ``spaces/X/threads/Y`` resource name).
1778
+
1779
+ If a typing card is tracked for this chat, transform it in-place via
1780
+ ``messages.patch`` — NO delete+create. Google Chat shows a tombstone
1781
+ ("Message deleted by its author") on delete, which is visual noise.
1782
+ Patch rewrites the text of the existing message seamlessly.
1783
+
1784
+ Also pauses the base class's ``_keep_typing`` loop for this chat so
1785
+ it can't post a racing typing card between the patch and the reply.
1786
+
1787
+ If ``content`` exceeds MAX_MESSAGE_LENGTH, the first chunk patches
1788
+ the typing card (if any), subsequent chunks are new messages.
1789
+ """
1790
+ thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id)
1791
+ self.pause_typing_for_chat(chat_id)
1792
+ try:
1793
+ # Convert standard Markdown emitted by the LLM to Chat's dialect
1794
+ # and strip invisible Unicode that renders as tofu (□). Runs
1795
+ # BEFORE chunking so the size limit applies to the rendered
1796
+ # form, not the source markdown.
1797
+ chunks = self._chunk_text(self.format_message(content))
1798
+ if not chunks:
1799
+ return SendResult(success=False, error="empty message")
1800
+
1801
+ last_result: Optional[SendResult] = None
1802
+ typing_msg_name = self._typing_messages.pop(chat_id, None)
1803
+ # Treat any earlier sentinel as "no real card to patch" — defensive.
1804
+ if typing_msg_name == _TYPING_CONSUMED_SENTINEL:
1805
+ typing_msg_name = None
1806
+ patched_typing = False
1807
+
1808
+ for idx, chunk in enumerate(chunks):
1809
+ body: Dict[str, Any] = {"text": chunk}
1810
+ # Only set thread on new-message create path. Patch inherits.
1811
+ if thread_id and (idx > 0 or not typing_msg_name):
1812
+ body["thread"] = {"name": thread_id}
1813
+ try:
1814
+ if idx == 0 and typing_msg_name:
1815
+ result = await self._patch_message(typing_msg_name, body)
1816
+ patched_typing = True
1817
+ else:
1818
+ result = await self._create_message(chat_id, body)
1819
+ last_result = result
1820
+ except HttpError as exc:
1821
+ status = getattr(getattr(exc, "resp", None), "status", None)
1822
+ if status == 403:
1823
+ self._set_fatal_error(
1824
+ code="chat_forbidden",
1825
+ message="Bot lacks access (removed from space or perms revoked)",
1826
+ retryable=False,
1827
+ )
1828
+ return SendResult(success=False, error=str(exc))
1829
+ if status == 404:
1830
+ # Typing card was deleted out from under us, or space
1831
+ # is gone. Fall through to creating a new message on
1832
+ # the first-chunk patch failure.
1833
+ if idx == 0 and typing_msg_name:
1834
+ logger.info(
1835
+ "[GoogleChat] Typing card disappeared; creating new message"
1836
+ )
1837
+ typing_msg_name = None
1838
+ result = await self._create_message(chat_id, body)
1839
+ last_result = result
1840
+ continue
1841
+ logger.info("[GoogleChat] send target 404; skipping")
1842
+ return SendResult(success=False, error="target not found")
1843
+ if status == 429:
1844
+ self._rate_limit_hits[chat_id] = (
1845
+ self._rate_limit_hits.get(chat_id, 0) + 1
1846
+ )
1847
+ if self._rate_limit_hits[chat_id] >= _RATE_LIMIT_WARN_THRESHOLD:
1848
+ logger.warning(
1849
+ "[GoogleChat] Rate limit hit %d times on chat; throttling",
1850
+ self._rate_limit_hits[chat_id],
1851
+ )
1852
+ raise
1853
+ raise
1854
+ if last_result is None:
1855
+ return SendResult(success=False, error="empty message")
1856
+ # Mark the chat's typing slot as "consumed" so the base class's
1857
+ # _keep_typing loop (which may iterate one more time before
1858
+ # typing_task.cancel() lands) does not post a fresh marker that
1859
+ # the safety-net stop_typing would then delete and tombstone.
1860
+ # Cleared in on_processing_complete.
1861
+ if patched_typing:
1862
+ self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL
1863
+ return last_result
1864
+ finally:
1865
+ self.resume_typing_for_chat(chat_id)
1866
+
1867
+ async def edit_message(
1868
+ self,
1869
+ chat_id: str,
1870
+ message_id: str,
1871
+ content: str,
1872
+ *,
1873
+ finalize: bool = False,
1874
+ ) -> SendResult:
1875
+ """Edit a previously sent message via ``messages.patch``.
1876
+
1877
+ Required for the gateway tool-progress + token-streaming pipeline:
1878
+ ``GatewayStreamConsumer`` and ``send_progress_messages`` both gate
1879
+ on this method being overridden (see gateway/run.py:10199 and
1880
+ gateway/stream_consumer.py). Without it, Google Chat shows no
1881
+ tool activity (no "🔍 web_search…", no progressive token edits).
1882
+
1883
+ ``message_id`` is the Google Chat resource name
1884
+ ``spaces/X/messages/Y``. ``finalize`` is unused here — Google
1885
+ Chat's patch API has no streaming lifecycle state, so the same
1886
+ patch closes the stream and any prior edit.
1887
+
1888
+ 404 (message gone) and 403 (perms revoked) are reported as
1889
+ non-success; the gateway falls back to ``send()`` for the next
1890
+ edit cycle.
1891
+ """
1892
+ if not message_id:
1893
+ return SendResult(success=False, error="missing message_id")
1894
+ # Google Chat caps message text at 4096; we use 4000 elsewhere.
1895
+ if len(content) > _MAX_TEXT_LENGTH:
1896
+ content = content[: _MAX_TEXT_LENGTH - 1] + "…"
1897
+ try:
1898
+ return await self._patch_message(message_id, {"text": content})
1899
+ except HttpError as exc:
1900
+ status = getattr(getattr(exc, "resp", None), "status", None)
1901
+ if status == 429:
1902
+ self._rate_limit_hits[chat_id] = (
1903
+ self._rate_limit_hits.get(chat_id, 0) + 1
1904
+ )
1905
+ return SendResult(
1906
+ success=False, error=_redact_sensitive(str(exc))
1907
+ )
1908
+ except Exception as exc:
1909
+ logger.debug("[GoogleChat] edit_message failed", exc_info=True)
1910
+ return SendResult(success=False, error=str(exc))
1911
+
1912
+ async def delete_message(self, chat_id: str, message_id: str) -> bool:
1913
+ """Delete a message — used sparingly (deletion creates a tombstone).
1914
+
1915
+ The base contract returns False on unsupported. We do support it,
1916
+ but most internal code should prefer ``edit_message`` to avoid the
1917
+ "Message deleted by its author" tombstone. Provided so the
1918
+ gateway's stream-consumer fallback paths (e.g. removing an aborted
1919
+ partial preview) work correctly when explicit deletion is the
1920
+ right call.
1921
+ """
1922
+ if not message_id:
1923
+ return False
1924
+
1925
+ def _do_delete() -> None:
1926
+ (
1927
+ self._chat_api.spaces()
1928
+ .messages()
1929
+ .delete(name=message_id)
1930
+ .execute(http=self._new_authed_http())
1931
+ )
1932
+
1933
+ try:
1934
+ await asyncio.to_thread(_do_delete)
1935
+ return True
1936
+ except HttpError as exc:
1937
+ status = getattr(getattr(exc, "resp", None), "status", None)
1938
+ if status in (403, 404):
1939
+ return False
1940
+ logger.debug(
1941
+ "[GoogleChat] delete_message failed: %s",
1942
+ _redact_sensitive(str(exc)),
1943
+ )
1944
+ return False
1945
+ except Exception:
1946
+ logger.debug("[GoogleChat] delete_message failed", exc_info=True)
1947
+ return False
1948
+
1949
+ async def _patch_message(
1950
+ self, message_name: str, body: Dict[str, Any]
1951
+ ) -> SendResult:
1952
+ """Update a message's text (and optionally cards) in-place."""
1953
+ update_mask_fields = []
1954
+ if "text" in body:
1955
+ update_mask_fields.append("text")
1956
+ if "cardsV2" in body:
1957
+ update_mask_fields.append("cardsV2")
1958
+ update_mask = ",".join(update_mask_fields) or "text"
1959
+
1960
+ # Patch body cannot carry thread (immutable).
1961
+ patch_body = {k: v for k, v in body.items() if k not in ("thread",)}
1962
+
1963
+ def _do_patch() -> Dict[str, Any]:
1964
+ return (
1965
+ self._chat_api.spaces()
1966
+ .messages()
1967
+ .patch(name=message_name, updateMask=update_mask, body=patch_body)
1968
+ .execute(http=self._new_authed_http())
1969
+ )
1970
+
1971
+ resp = await asyncio.to_thread(_do_patch)
1972
+ return SendResult(success=True, message_id=resp.get("name", message_name))
1973
+
1974
+ def _chunk_text(self, text: str) -> List[str]:
1975
+ if not text:
1976
+ return []
1977
+ if len(text) <= _MAX_TEXT_LENGTH:
1978
+ return [text]
1979
+ chunks: List[str] = []
1980
+ remaining = text
1981
+ while remaining:
1982
+ if len(remaining) <= _MAX_TEXT_LENGTH:
1983
+ chunks.append(remaining)
1984
+ break
1985
+ # Try to split on a newline near the cutoff.
1986
+ cut = remaining.rfind("\n", 0, _MAX_TEXT_LENGTH)
1987
+ if cut < _MAX_TEXT_LENGTH // 2:
1988
+ cut = _MAX_TEXT_LENGTH
1989
+ chunks.append(remaining[:cut])
1990
+ remaining = remaining[cut:].lstrip()
1991
+ return chunks
1992
+
1993
+ # ------------------------------------------------------------------
1994
+ # Outbound formatting
1995
+ # ------------------------------------------------------------------
1996
+ # Invisible Unicode codepoints that render as tofu (□) in Google
1997
+ # Chat's restricted font stack. ZWJ/ZWNJ/ZWS are the glue inside
1998
+ # composite emoji and bidirectional text; Variation Selectors
1999
+ # control text-vs-emoji presentation but Chat ignores them and
2000
+ # often shows a blank box. Pattern lifted from PR #14965.
2001
+ _INVISIBLE_RE = re.compile(
2002
+ "["
2003
+ "​" # Zero-Width Space
2004
+ "‌" # Zero-Width Non-Joiner
2005
+ "‍" # Zero-Width Joiner (ZWJ)
2006
+ "‎‏" # LTR / RTL marks
2007
+ "⁠" # Word Joiner
2008
+ "" # BOM / Zero-Width No-Break Space
2009
+ "︀-️" # Variation Selectors 1-16 (VS1–VS16)
2010
+ "\U000e0100-\U000e01ef" # Variation Selectors 17-256
2011
+ "]"
2012
+ )
2013
+
2014
+ @classmethod
2015
+ def format_message(cls, content: str) -> str:
2016
+ """Convert standard Markdown to Google Chat's formatting dialect.
2017
+
2018
+ Google Chat renders a small subset: ``*bold*``, ``_italic_``,
2019
+ ``~strikethrough~``, fenced/inline code. Standard Markdown
2020
+ constructs (``**bold**``, ``# headers``, ``[text](url)``) do
2021
+ not render and need conversion before they reach Chat.
2022
+
2023
+ Code blocks (fenced AND inline) are protected from transformation
2024
+ via placeholder substitution so backticks-wrapped content with
2025
+ literal asterisks or brackets stays intact. Invisible Unicode
2026
+ codepoints that render as tofu in Chat's restricted font stack
2027
+ are stripped at the end. Empty/None input passes through.
2028
+
2029
+ Pattern lifted from PR #14965.
2030
+ """
2031
+ if not content:
2032
+ return content
2033
+
2034
+ text = content
2035
+ placeholders: Dict[str, str] = {}
2036
+ counter = [0]
2037
+
2038
+ def _ph(value: str) -> str:
2039
+ key = f"\x00GC{counter[0]}\x00"
2040
+ counter[0] += 1
2041
+ placeholders[key] = value
2042
+ return key
2043
+
2044
+ # Protect fenced and inline code blocks from transformation.
2045
+ # Fenced blocks first (``` ... ```), then inline code (`...`).
2046
+ text = re.sub(
2047
+ r"(```(?:[^\n]*\n)?[\s\S]*?```)",
2048
+ lambda m: _ph(m.group(0)),
2049
+ text,
2050
+ )
2051
+ text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text)
2052
+
2053
+ # Headers (## Title) → *Title* (Chat has no header support).
2054
+ text = re.sub(
2055
+ r"^#{1,6}\s+(.+)$",
2056
+ lambda m: _ph(f"*{m.group(1).strip()}*"),
2057
+ text,
2058
+ flags=re.MULTILINE,
2059
+ )
2060
+
2061
+ # Bold+italic: ***text*** → *_text_*
2062
+ text = re.sub(
2063
+ r"\*\*\*(.+?)\*\*\*",
2064
+ lambda m: _ph(f"*_{m.group(1)}_*"),
2065
+ text,
2066
+ )
2067
+
2068
+ # Bold: **text** → *text* (Chat uses single asterisks).
2069
+ text = re.sub(
2070
+ r"\*\*(.+?)\*\*",
2071
+ lambda m: _ph(f"*{m.group(1)}*"),
2072
+ text,
2073
+ )
2074
+
2075
+ # Markdown links [text](url) → <url|text> (Slack-style angle-bracket).
2076
+ text = re.sub(
2077
+ r"\[([^\]]+)\]\(([^)]+)\)",
2078
+ lambda m: _ph(f"<{m.group(2)}|{m.group(1)}>"),
2079
+ text,
2080
+ )
2081
+
2082
+ # Strip invisible Unicode that renders as tofu.
2083
+ text = cls._INVISIBLE_RE.sub("", text)
2084
+
2085
+ # Collapse double spaces left over from stripped chars.
2086
+ text = re.sub(r" +", " ", text)
2087
+
2088
+ # Restore protected regions.
2089
+ for key, value in placeholders.items():
2090
+ text = text.replace(key, value)
2091
+
2092
+ return text
2093
+
2094
+ def _resolve_thread_id(
2095
+ self,
2096
+ reply_to: Optional[str],
2097
+ metadata: Optional[Dict[str, Any]],
2098
+ chat_id: Optional[str] = None,
2099
+ ) -> Optional[str]:
2100
+ """Return the Google Chat thread resource name to reply under, or None.
2101
+
2102
+ Priority:
2103
+ 1. ``metadata['thread_id']`` — populated by the gateway's session
2104
+ plumbing from ``SessionSource.thread_id`` (the inbound
2105
+ ``thread.name``). Canonical path for groups.
2106
+ 2. ``metadata['thread_name']`` / ``metadata['thread_ts']`` — Slack
2107
+ precedent aliases that the broader codebase sometimes passes.
2108
+ 3. ``reply_to`` if it already looks like a thread resource name
2109
+ (``spaces/X/threads/Y``). Message names ``spaces/X/messages/Y``
2110
+ cannot be converted to threads without an extra API call.
2111
+ 4. ``self._last_inbound_thread[chat_id]`` — Google Chat DMs spawn
2112
+ a new thread per top-level user message, and the adapter
2113
+ intentionally drops thread_id from the source so the session
2114
+ key stays stable. Without this fallback, DM replies would
2115
+ land at top-level (a fresh thread separate from the user's),
2116
+ visually disconnected from the user's question.
2117
+ """
2118
+ if metadata:
2119
+ for key in ("thread_id", "thread_name", "thread_ts"):
2120
+ value = metadata.get(key)
2121
+ if value:
2122
+ return str(value)
2123
+ if reply_to and "/threads/" in reply_to and "/messages/" not in reply_to:
2124
+ return reply_to
2125
+ if chat_id:
2126
+ cached = self._last_inbound_thread.get(chat_id)
2127
+ if cached:
2128
+ return cached
2129
+ return None
2130
+
2131
+ def _new_authed_http(self) -> Any:
2132
+ """Return a fresh AuthorizedHttp.
2133
+
2134
+ googleapiclient's discovery client is NOT thread-safe because httplib2
2135
+ shares SSL state between calls. Passing a fresh http= to each
2136
+ ``execute()`` avoids record-layer failures when calls run in
2137
+ ``asyncio.to_thread`` workers. Cheap (~no network).
2138
+ """
2139
+ return AuthorizedHttp(self._credentials, http=httplib2.Http(timeout=30))
2140
+
2141
+ async def _call_with_retry(
2142
+ self,
2143
+ sync_fn: Callable[[], Any],
2144
+ *,
2145
+ op_name: str = "chat-api-call",
2146
+ ) -> Any:
2147
+ """Run ``sync_fn`` in a thread with bounded retry + jittered backoff.
2148
+
2149
+ Wraps a sync Chat API call (typically a ``.execute()``) so transient
2150
+ 429/5xx/timeout failures don't drop user-visible messages. Permanent
2151
+ failures (auth, client errors, validation) bubble up on the first
2152
+ attempt — see :func:`_is_retryable_error`. Cancellation propagates
2153
+ immediately, no extra retries after a CancelledError.
2154
+
2155
+ Pattern lifted from PR #14965.
2156
+ """
2157
+ delay = _RETRY_BASE_DELAY
2158
+ last_exc: Optional[BaseException] = None
2159
+ for attempt in range(1, _RETRY_MAX_ATTEMPTS + 1):
2160
+ try:
2161
+ return await asyncio.to_thread(sync_fn)
2162
+ except asyncio.CancelledError:
2163
+ raise
2164
+ except Exception as exc:
2165
+ last_exc = exc
2166
+ retryable = _is_retryable_error(exc)
2167
+ if not retryable or attempt >= _RETRY_MAX_ATTEMPTS:
2168
+ raise
2169
+ jitter = delay * _RETRY_JITTER * random.random()
2170
+ wait = min(delay + jitter, _RETRY_MAX_DELAY + _RETRY_JITTER)
2171
+ logger.warning(
2172
+ "[GoogleChat] %s attempt %d/%d failed (%s); "
2173
+ "retrying in %.2fs",
2174
+ op_name, attempt, _RETRY_MAX_ATTEMPTS,
2175
+ _redact_sensitive(str(exc)), wait,
2176
+ )
2177
+ try:
2178
+ await asyncio.sleep(wait)
2179
+ except asyncio.CancelledError:
2180
+ raise
2181
+ delay = min(delay * 2, _RETRY_MAX_DELAY)
2182
+ # Defensive — the loop above always either returns or re-raises.
2183
+ if last_exc is not None:
2184
+ raise last_exc
2185
+ raise RuntimeError(f"{op_name}: retry loop exited without result")
2186
+
2187
+ async def _create_message(
2188
+ self, chat_id: str, body: Dict[str, Any]
2189
+ ) -> SendResult:
2190
+ """POST spaces/{space}/messages via REST, returning SendResult.
2191
+
2192
+ When ``body`` carries ``thread.name``, we MUST pass
2193
+ ``messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`` —
2194
+ otherwise Google Chat silently ignores ``thread.name`` and
2195
+ creates a new thread anyway. From the official docs:
2196
+
2197
+ "Default. Starts a new thread. Using this option ignores
2198
+ any thread ID or threadKey that's included."
2199
+
2200
+ See https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/create
2201
+ """
2202
+ kwargs: Dict[str, Any] = {"parent": chat_id, "body": body}
2203
+ thread_meta = body.get("thread") or {}
2204
+ if thread_meta.get("name"):
2205
+ # FALLBACK_TO_NEW_THREAD: try the requested thread; if Chat
2206
+ # can't route there (e.g. thread no longer exists), create a
2207
+ # new one rather than erroring. Safer than REPLY_MESSAGE_OR_FAIL
2208
+ # for a chat-bot context where stale thread names are rare
2209
+ # but possible.
2210
+ kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
2211
+
2212
+ def _do_create() -> Dict[str, Any]:
2213
+ return (
2214
+ self._chat_api.spaces()
2215
+ .messages()
2216
+ .create(**kwargs)
2217
+ .execute(http=self._new_authed_http())
2218
+ )
2219
+
2220
+ resp = await self._call_with_retry(_do_create, op_name="messages.create")
2221
+ # Track outbound destination thread in the persistent count store
2222
+ # so a future user "Reply in thread" on the bot's message resolves
2223
+ # to a known thread (prev_count >= 1 → side thread). Without
2224
+ # this, threads created by the bot's own outbound look fresh
2225
+ # the first time the user engages them, and the heuristic
2226
+ # incorrectly classifies the engagement as main-flow → bot
2227
+ # replies at top-level instead of in the thread.
2228
+ resp_thread = (resp.get("thread") or {}).get("name") or ""
2229
+ if chat_id and resp_thread:
2230
+ try:
2231
+ self._thread_count_store.incr(chat_id, resp_thread)
2232
+ except Exception:
2233
+ logger.debug(
2234
+ "[GoogleChat] outbound thread-count incr failed",
2235
+ exc_info=True,
2236
+ )
2237
+ return SendResult(success=True, message_id=resp.get("name"))
2238
+
2239
+ async def send_typing(self, chat_id: str, metadata: Any = None) -> None:
2240
+ """Post a visible 'Hermes is thinking…' marker message.
2241
+
2242
+ NOT ephemeral (Google Chat has no ephemeral text messages outside
2243
+ slash command responses). ``send()`` PATCHes this marker in-place
2244
+ with the real response (no deletion tombstone). The typing card is
2245
+ either patched by ``send()`` (success) or by
2246
+ ``on_processing_complete`` (failure / cancellation).
2247
+
2248
+ IMPORTANT — must place the typing card in the user's thread:
2249
+ ``messages.patch`` cannot change a message's ``thread`` (it's
2250
+ immutable on update). If we create the typing card at top-level
2251
+ and the user is replying inside thread T, send() will patch the
2252
+ top-level card in place — leaving the bot's whole response
2253
+ stranded outside the user's thread. We resolve the thread the
2254
+ same way send() does.
2255
+
2256
+ IMPORTANT — cancellation safety:
2257
+ ``base.py``'s ``_keep_typing`` calls this through
2258
+ ``asyncio.wait_for(send_typing, timeout=1.5)``. When the
2259
+ create-API call takes longer than 1.5s, ``wait_for`` cancels
2260
+ ``send_typing`` mid-flight — but the underlying ``asyncio.to_thread``
2261
+ keeps running and creates a card in Chat that we have NO way to
2262
+ track (the storage line never runs). Next ``_keep_typing`` tick
2263
+ sees an empty slot and creates a SECOND card. Result: one orphan
2264
+ "Hermes is thinking…" stuck in chat forever, plus one card that
2265
+ gets patched into the reply.
2266
+
2267
+ Fix: reserve the slot with an in-flight ``Event``, run the
2268
+ create in a background task, and ``await asyncio.shield`` it.
2269
+ Cancellation of THIS coroutine no longer cancels the create —
2270
+ the task runs to completion and the msg_id lands in the slot
2271
+ regardless.
2272
+ """
2273
+ # Already have a card (real msg_id, sentinel, or in-flight) — bail.
2274
+ if chat_id in self._typing_messages:
2275
+ return
2276
+ if chat_id in self._typing_card_inflight:
2277
+ # Another create is already running for this chat. Wait for
2278
+ # it to finish so we honor the contract "if called, the card
2279
+ # is up by the time we return". Bounded wait — if the
2280
+ # background task is stuck, _keep_typing will retry.
2281
+ try:
2282
+ await asyncio.wait_for(
2283
+ self._typing_card_inflight[chat_id].wait(),
2284
+ timeout=5.0,
2285
+ )
2286
+ except (asyncio.TimeoutError, KeyError):
2287
+ pass
2288
+ return
2289
+
2290
+ thread_id = self._resolve_thread_id(
2291
+ reply_to=None, metadata=metadata, chat_id=chat_id,
2292
+ )
2293
+ body: Dict[str, Any] = {"text": "Hermes is thinking…"}
2294
+ if thread_id:
2295
+ body["thread"] = {"name": thread_id}
2296
+
2297
+ completed = asyncio.Event()
2298
+ self._typing_card_inflight[chat_id] = completed
2299
+
2300
+ async def _create_and_record() -> None:
2301
+ try:
2302
+ result = await self._create_message(chat_id, body)
2303
+ if result.success and result.message_id:
2304
+ # Only overwrite the slot if nothing else has claimed it
2305
+ # in the meantime (e.g. send() racing ahead of us).
2306
+ if chat_id not in self._typing_messages:
2307
+ self._typing_messages[chat_id] = result.message_id
2308
+ else:
2309
+ # Slot already populated — likely send() patched
2310
+ # something or another create completed first.
2311
+ # Our card is ORPHANED here, but at least it's a
2312
+ # known orphan we can clean up at end of turn.
2313
+ # Track for cleanup by on_processing_complete.
2314
+ self._orphan_typing_messages.setdefault(
2315
+ chat_id, []
2316
+ ).append(result.message_id)
2317
+ except Exception:
2318
+ logger.debug(
2319
+ "[GoogleChat] send_typing background create failed",
2320
+ exc_info=True,
2321
+ )
2322
+ finally:
2323
+ self._typing_card_inflight.pop(chat_id, None)
2324
+ completed.set()
2325
+
2326
+ task = asyncio.create_task(_create_and_record())
2327
+ # Shield the task from cancellation of our awaiter. If
2328
+ # _keep_typing's wait_for times out, our coroutine is cancelled
2329
+ # but the task continues in the background — so the msg_id
2330
+ # eventually lands in the slot even when the API call is slow.
2331
+ try:
2332
+ await asyncio.shield(task)
2333
+ except asyncio.CancelledError:
2334
+ # The shielded task keeps running. Re-raise so the caller's
2335
+ # cancellation semantics are preserved.
2336
+ raise
2337
+
2338
+ async def stop_typing(self, chat_id: str) -> None:
2339
+ """Stop the typing indicator — NO-OP when a live card is tracked.
2340
+
2341
+ Google Chat has no separate typing API: the "Hermes is thinking…"
2342
+ marker is a real message that ``send()`` patches in-place with the
2343
+ agent's reply. Deleting the marker creates a "Message deleted by
2344
+ its author" tombstone, which is visual noise.
2345
+
2346
+ Upstream code (gateway/run.py and gateway/platforms/base.py) calls
2347
+ ``stop_typing`` at three moments per turn — typically BEFORE
2348
+ ``send()`` runs (so deleting the slot would leave ``send()``
2349
+ nothing to patch, forcing it to create a fresh message and leaving
2350
+ the original card as a tombstone). To fix this without modifying
2351
+ upstream contracts, ``stop_typing`` here is intentionally a NO-OP
2352
+ when the slot holds a real ``message_name``: the card is left in
2353
+ place so ``send()`` can patch it.
2354
+
2355
+ Three cases:
2356
+ * Slot empty → nothing to do.
2357
+ * Slot holds SENTINEL → ``send()`` already patched the card;
2358
+ pop the sentinel so the next turn starts clean.
2359
+ * Slot holds a real ``message_name`` → leave it for ``send()``
2360
+ to consume. NO-OP.
2361
+
2362
+ Stranded cards on error / cancellation paths (where ``send()``
2363
+ never runs) are reaped by ``on_processing_complete`` — see that
2364
+ hook for the patch-to-final-state cleanup.
2365
+ """
2366
+ current = self._typing_messages.get(chat_id)
2367
+ if not current:
2368
+ return
2369
+ if current == _TYPING_CONSUMED_SENTINEL:
2370
+ self._typing_messages.pop(chat_id, None)
2371
+ return
2372
+ # Real message_name — leave it for send() to patch. Deliberate no-op.
2373
+ return
2374
+
2375
+ async def on_processing_complete(
2376
+ self, event: MessageEvent, outcome: ProcessingOutcome
2377
+ ) -> None:
2378
+ """Reap typing card(s) after the message-handling cycle ends.
2379
+
2380
+ SUCCESS: ``send()`` set the SENTINEL after patching. Pop it.
2381
+
2382
+ FAILURE / CANCELLED: ``send()`` may not have run, leaving a real
2383
+ ``message_name`` in the slot. Patching the card to a final state
2384
+ (``"(interrupted)"``) avoids the tombstone that ``messages.delete``
2385
+ would create. If ``send()`` did run (e.g. base.py error-send branch
2386
+ patched it), the slot holds the SENTINEL — pop and exit.
2387
+
2388
+ Orphan cards: when a background ``send_typing`` task creates a
2389
+ card AFTER ``send()`` already populated the slot (race window
2390
+ when the API call takes longer than _keep_typing's wait_for
2391
+ timeout), the orphan id is stashed in ``self._orphan_typing_messages``.
2392
+ Patch each orphan with an empty-ish marker so the user doesn't
2393
+ see "Hermes is thinking…" stuck forever.
2394
+ """
2395
+ if event.source is None:
2396
+ return
2397
+ chat_id = event.source.chat_id
2398
+ try:
2399
+ current = self._typing_messages.pop(chat_id, None)
2400
+ if current and current != _TYPING_CONSUMED_SENTINEL:
2401
+ # Real message_name still in slot — send() never ran. Patch
2402
+ # with a benign final state instead of deleting (no tombstone).
2403
+ label = (
2404
+ "(interrupted)" if outcome == ProcessingOutcome.CANCELLED
2405
+ else "(no reply)"
2406
+ )
2407
+ try:
2408
+ await self._patch_message(current, {"text": label})
2409
+ except Exception:
2410
+ logger.debug(
2411
+ "[GoogleChat] on_processing_complete patch fallback failed",
2412
+ exc_info=True,
2413
+ )
2414
+ # Reap orphan typing cards (background creates that lost a
2415
+ # race with send()). Patch them to a single dot so they
2416
+ # gracefully retire — the user already saw the real reply
2417
+ # in another card, this one is just visual noise to clear.
2418
+ orphans = self._orphan_typing_messages.pop(chat_id, [])
2419
+ for orphan_id in orphans:
2420
+ try:
2421
+ await self._patch_message(orphan_id, {"text": "·"})
2422
+ except Exception:
2423
+ logger.debug(
2424
+ "[GoogleChat] orphan typing-card patch failed: %s",
2425
+ orphan_id, exc_info=True,
2426
+ )
2427
+ except Exception:
2428
+ logger.debug(
2429
+ "[GoogleChat] cleanup in on_processing_complete failed", exc_info=True
2430
+ )
2431
+
2432
+ # ------------------------------------------------------------------
2433
+ # Attachment send paths
2434
+ # ------------------------------------------------------------------
2435
+ async def _consume_typing_card_with_text(
2436
+ self, chat_id: str, text: str
2437
+ ) -> Optional[SendResult]:
2438
+ """Patch the tracked typing card with ``text`` (no tombstone).
2439
+
2440
+ Returns ``None`` if there's no real typing card to patch (caller
2441
+ should create a new message). Returns the patch result if the
2442
+ card was successfully patched. Raises on transient HttpErrors so
2443
+ the caller can decide whether to fall back to ``_create_message``.
2444
+
2445
+ Leaves the SENTINEL in place when present: a previous ``send()``
2446
+ already consumed the typing card, and the SENTINEL must stay in
2447
+ the slot to keep the base class's ``_keep_typing`` loop from
2448
+ creating a fresh "Hermes is thinking…" card during any subsequent
2449
+ attachment send (which would later be reaped as "(no reply)").
2450
+ """
2451
+ current = self._typing_messages.get(chat_id)
2452
+ if not current or current == _TYPING_CONSUMED_SENTINEL:
2453
+ return None
2454
+ # Real msg_id — pop and patch.
2455
+ self._typing_messages.pop(chat_id, None)
2456
+ try:
2457
+ result = await self._patch_message(current, {"text": text})
2458
+ self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL
2459
+ return result
2460
+ except HttpError as exc:
2461
+ status = getattr(getattr(exc, "resp", None), "status", None)
2462
+ if status == 404:
2463
+ # Card disappeared — caller should create a new message.
2464
+ return None
2465
+ raise
2466
+
2467
+ async def send_image(
2468
+ self,
2469
+ chat_id: str,
2470
+ image_url: str,
2471
+ caption: Optional[str] = None,
2472
+ reply_to: Optional[str] = None,
2473
+ metadata: Optional[Dict[str, Any]] = None,
2474
+ ) -> SendResult:
2475
+ """Send an inline image via attachment URL (no upload).
2476
+
2477
+ If a typing card is tracked for this chat, patch it in-place with
2478
+ the image (caption + URL) — same anti-tombstone pattern used by
2479
+ ``send()``. Otherwise create a new message.
2480
+ """
2481
+ thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id)
2482
+ text_parts: List[str] = []
2483
+ if caption:
2484
+ text_parts.append(caption)
2485
+ text_parts.append(image_url)
2486
+ text = "\n".join(text_parts)
2487
+
2488
+ try:
2489
+ patched = await self._consume_typing_card_with_text(chat_id, text)
2490
+ if patched is not None:
2491
+ return patched
2492
+ body: Dict[str, Any] = {"text": text}
2493
+ if thread_id:
2494
+ body["thread"] = {"name": thread_id}
2495
+ return await self._create_message(chat_id, body)
2496
+ except HttpError as exc:
2497
+ return SendResult(success=False, error=_redact_sensitive(str(exc)))
2498
+
2499
+ async def send_image_file(
2500
+ self,
2501
+ chat_id: str,
2502
+ image_path: str,
2503
+ caption: Optional[str] = None,
2504
+ reply_to: Optional[str] = None,
2505
+ **kwargs: Any,
2506
+ ) -> SendResult:
2507
+ return await self._send_file(
2508
+ chat_id, image_path, caption,
2509
+ mime_hint="image/*",
2510
+ thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
2511
+ )
2512
+
2513
+ async def send_document(
2514
+ self,
2515
+ chat_id: str,
2516
+ file_path: str,
2517
+ caption: Optional[str] = None,
2518
+ file_name: Optional[str] = None,
2519
+ reply_to: Optional[str] = None,
2520
+ **kwargs: Any,
2521
+ ) -> SendResult:
2522
+ return await self._send_file(
2523
+ chat_id, file_path, caption,
2524
+ mime_hint=None,
2525
+ thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
2526
+ override_filename=file_name,
2527
+ )
2528
+
2529
+ async def send_voice(
2530
+ self,
2531
+ chat_id: str,
2532
+ audio_path: str,
2533
+ caption: Optional[str] = None,
2534
+ reply_to: Optional[str] = None,
2535
+ **kwargs: Any,
2536
+ ) -> SendResult:
2537
+ return await self._send_file(
2538
+ chat_id, audio_path, caption,
2539
+ mime_hint="audio/ogg",
2540
+ thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
2541
+ )
2542
+
2543
+ async def send_video(
2544
+ self,
2545
+ chat_id: str,
2546
+ video_path: str,
2547
+ caption: Optional[str] = None,
2548
+ reply_to: Optional[str] = None,
2549
+ **kwargs: Any,
2550
+ ) -> SendResult:
2551
+ return await self._send_file(
2552
+ chat_id, video_path, caption,
2553
+ mime_hint="video/mp4",
2554
+ thread_id=self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id),
2555
+ )
2556
+
2557
+ async def send_animation(
2558
+ self,
2559
+ chat_id: str,
2560
+ animation_url: str,
2561
+ caption: Optional[str] = None,
2562
+ reply_to: Optional[str] = None,
2563
+ metadata: Optional[Dict[str, Any]] = None,
2564
+ ) -> SendResult:
2565
+ """Google Chat has no native animation type; fall back to send_image."""
2566
+ return await self.send_image(
2567
+ chat_id, animation_url, caption=caption,
2568
+ reply_to=reply_to, metadata=metadata,
2569
+ )
2570
+
2571
+ # ------------------------------------------------------------------
2572
+ # Native attachment delivery via user OAuth
2573
+ #
2574
+ # Google Chat's media.upload endpoint hard-rejects SA authentication
2575
+ # ("This method doesn't support app authentication with a service
2576
+ # account"). The bot itself cannot upload files. Instead the user
2577
+ # grants the bot the chat.messages.create scope ONCE via an in-chat
2578
+ # OAuth consent flow (``/setup-files``); the resulting refresh token
2579
+ # lets the bot call media.upload AS the user, producing native Chat
2580
+ # attachments (file widget, inline preview, click-to-download).
2581
+ #
2582
+ # See https://developers.google.com/chat/api/guides/auth/users for
2583
+ # the upstream limitation that makes user OAuth necessary, and
2584
+ # ``plugins/platforms/google_chat/oauth.py`` for the helper
2585
+ # script + library functions backing this path.
2586
+ # ------------------------------------------------------------------
2587
+ @staticmethod
2588
+ def _is_app_auth_attachment_error(exc: HttpError) -> bool:
2589
+ """Detect Google Chat's media.upload bot-auth rejection.
2590
+
2591
+ Returns True for the canonical ``"doesn't support app
2592
+ authentication"`` wording (and the legacy
2593
+ ``ACCESS_TOKEN_SCOPE_INSUFFICIENT`` variant some older clients
2594
+ still see). Used to flag a misuse — calling ``media.upload``
2595
+ through the SA-authed Chat API client instead of the user-authed
2596
+ one. With correct routing this error should never fire in the
2597
+ adapter; it remains as a defensive check.
2598
+ """
2599
+ text = str(exc) or ""
2600
+ return (
2601
+ "doesn't support app authentication" in text
2602
+ or "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in text
2603
+ )
2604
+
2605
+ _LEGACY_USER_IDENTITY = "__legacy__"
2606
+
2607
+ async def _load_per_user_chat_api(self, email: str) -> Optional[Any]:
2608
+ """Get (or build + cache) a user-authed Chat client for ``email``.
2609
+
2610
+ Hits ``self._user_chat_api_by_email`` first; on miss, loads the
2611
+ per-user token from disk, refreshes if needed, builds an API
2612
+ client, and caches both. Refresh failures evict the slot so the
2613
+ next request goes back through the disk path (and ultimately the
2614
+ text-notice fallback if the user has revoked).
2615
+ """
2616
+ from .oauth import (
2617
+ load_user_credentials as _load,
2618
+ build_user_chat_service as _build,
2619
+ refresh_or_none as _refresh,
2620
+ )
2621
+
2622
+ cached_api = self._user_chat_api_by_email.get(email)
2623
+ cached_creds = self._user_creds_by_email.get(email)
2624
+ if cached_api is not None and cached_creds is not None:
2625
+ try:
2626
+ refreshed = await asyncio.to_thread(_refresh, cached_creds, email)
2627
+ except Exception:
2628
+ logger.debug(
2629
+ "[GoogleChat] cached per-user refresh raised", exc_info=True,
2630
+ )
2631
+ refreshed = None
2632
+ if refreshed is None:
2633
+ self._user_chat_api_by_email.pop(email, None)
2634
+ self._user_creds_by_email.pop(email, None)
2635
+ return None
2636
+ self._user_creds_by_email[email] = refreshed
2637
+ return cached_api
2638
+
2639
+ try:
2640
+ creds = await asyncio.to_thread(_load, email)
2641
+ if creds is None:
2642
+ return None
2643
+ api = await asyncio.to_thread(lambda: _build(creds))
2644
+ except Exception:
2645
+ logger.debug(
2646
+ "[GoogleChat] per-user creds load/build failed for %s",
2647
+ email, exc_info=True,
2648
+ )
2649
+ return None
2650
+
2651
+ self._user_creds_by_email[email] = creds
2652
+ self._user_chat_api_by_email[email] = api
2653
+ return api
2654
+
2655
+ async def _acquire_user_chat_api(
2656
+ self, sender_email: Optional[str]
2657
+ ) -> Tuple[Optional[Any], Optional[str]]:
2658
+ """Resolve the user-authed Chat client for an outbound attachment.
2659
+
2660
+ Lookup order:
2661
+ 1. Per-user token for ``sender_email`` — the asker's identity.
2662
+ 2. Legacy single-user fallback (``self._user_chat_api``) for
2663
+ pre-multi-user installs.
2664
+ 3. None — caller posts the setup-instructions text notice.
2665
+
2666
+ Returns ``(client, identity_label)`` where ``identity_label`` is
2667
+ the sanitized email or the literal ``"__legacy__"`` sentinel.
2668
+ ``_invalidate_user_creds`` uses the label to evict the right slot
2669
+ on auth failure.
2670
+ """
2671
+ if sender_email:
2672
+ api = await self._load_per_user_chat_api(sender_email)
2673
+ if api is not None:
2674
+ return api, sender_email
2675
+
2676
+ if self._user_chat_api is not None:
2677
+ try:
2678
+ from .oauth import (
2679
+ refresh_or_none as _refresh,
2680
+ )
2681
+ refreshed = await asyncio.to_thread(
2682
+ _refresh, self._user_credentials, None,
2683
+ )
2684
+ except Exception:
2685
+ logger.debug(
2686
+ "[GoogleChat] legacy creds refresh raised", exc_info=True,
2687
+ )
2688
+ refreshed = None
2689
+ if refreshed is None:
2690
+ logger.warning(
2691
+ "[GoogleChat] legacy user-OAuth refresh returned None — "
2692
+ "evicting fallback creds"
2693
+ )
2694
+ self._user_credentials = None
2695
+ self._user_chat_api = None
2696
+ return None, None
2697
+ self._user_credentials = refreshed
2698
+ return self._user_chat_api, self._LEGACY_USER_IDENTITY
2699
+
2700
+ return None, None
2701
+
2702
+ def _invalidate_user_creds(self, identity: Optional[str]) -> None:
2703
+ """Drop creds for ``identity`` after an auth failure.
2704
+
2705
+ ``identity`` comes from ``_acquire_user_chat_api`` — either the
2706
+ sender email (per-user slot) or ``__legacy__`` for the fallback
2707
+ slot. None is a no-op.
2708
+ """
2709
+ if not identity:
2710
+ return
2711
+ if identity == self._LEGACY_USER_IDENTITY:
2712
+ self._user_credentials = None
2713
+ self._user_chat_api = None
2714
+ return
2715
+ self._user_creds_by_email.pop(identity, None)
2716
+ self._user_chat_api_by_email.pop(identity, None)
2717
+
2718
+ async def _send_file(
2719
+ self,
2720
+ chat_id: str,
2721
+ path: str,
2722
+ caption: Optional[str],
2723
+ mime_hint: Optional[str],
2724
+ thread_id: Optional[str] = None,
2725
+ override_filename: Optional[str] = None,
2726
+ ) -> SendResult:
2727
+ """Native Chat attachment via user-OAuth media.upload.
2728
+
2729
+ Two-step on the wire: ``media.upload`` then
2730
+ ``spaces.messages.create`` with the returned ``attachmentDataRef``.
2731
+ BOTH calls go through a user-authed Chat API client — the
2732
+ SA-authed client is rejected by ``media.upload`` regardless of
2733
+ scopes.
2734
+
2735
+ Multi-user routing: the bot looks up the most recent inbound
2736
+ sender for this ``chat_id`` and uses THAT user's stored OAuth
2737
+ token. Falls back to a legacy single-user token when present
2738
+ (for pre-multi-user installs), and to a setup-instructions text
2739
+ notice when neither is available.
2740
+
2741
+ Google Chat ``messages.patch`` cannot add an attachment to an
2742
+ existing message, so we cannot transform the typing card directly
2743
+ into the file message. Instead we patch the typing card with the
2744
+ caption (or a single space when none) so it retires without a
2745
+ tombstone, then create the attachment message.
2746
+ """
2747
+ if not os.path.exists(path):
2748
+ return SendResult(success=False, error=f"file not found: {path}")
2749
+
2750
+ filename = override_filename or os.path.basename(path) or "upload.bin"
2751
+ mime = mime_hint or "application/octet-stream"
2752
+
2753
+ sender_email = self._last_sender_by_chat.get(chat_id)
2754
+ chat_api, identity = await self._acquire_user_chat_api(sender_email)
2755
+
2756
+ # No user OAuth → can't upload natively. Surface clear setup
2757
+ # instructions in chat instead of silently failing.
2758
+ if chat_api is None:
2759
+ return await self._post_attachment_fallback(
2760
+ chat_id=chat_id,
2761
+ path=path,
2762
+ filename=filename,
2763
+ caption=caption,
2764
+ thread_id=thread_id,
2765
+ )
2766
+
2767
+ # Pre-patch the typing card with the caption (or single space) so
2768
+ # it retires without a tombstone before the attachment message is
2769
+ # posted.
2770
+ try:
2771
+ await self._consume_typing_card_with_text(chat_id, caption or " ")
2772
+ except Exception:
2773
+ logger.debug(
2774
+ "[GoogleChat] _send_file pre-patch typing-card failed",
2775
+ exc_info=True,
2776
+ )
2777
+
2778
+ def _upload() -> Dict[str, Any]:
2779
+ media = MediaFileUpload(path, mimetype=mime, resumable=False)
2780
+ return (
2781
+ chat_api.media()
2782
+ .upload(
2783
+ parent=chat_id,
2784
+ body={"filename": filename},
2785
+ media_body=media,
2786
+ )
2787
+ .execute()
2788
+ )
2789
+
2790
+ try:
2791
+ upload_resp = await asyncio.to_thread(_upload)
2792
+ except HttpError as exc:
2793
+ status = getattr(getattr(exc, "resp", None), "status", None)
2794
+ if status in (401, 403):
2795
+ logger.warning(
2796
+ "[GoogleChat] media.upload auth failure for identity=%s "
2797
+ "(token revoked or scope missing) — falling back to "
2798
+ "text notice. Status=%s", identity, status,
2799
+ )
2800
+ self._invalidate_user_creds(identity)
2801
+ return await self._post_attachment_fallback(
2802
+ chat_id=chat_id,
2803
+ path=path,
2804
+ filename=filename,
2805
+ caption=caption,
2806
+ thread_id=thread_id,
2807
+ )
2808
+ return SendResult(
2809
+ success=False, error=_redact_sensitive(str(exc))
2810
+ )
2811
+
2812
+ attachment_ref = upload_resp.get("attachmentDataRef")
2813
+ if not attachment_ref:
2814
+ return SendResult(
2815
+ success=False,
2816
+ error="upload returned no attachmentDataRef",
2817
+ )
2818
+
2819
+ body: Dict[str, Any] = {
2820
+ "attachment": [{"attachmentDataRef": attachment_ref}],
2821
+ }
2822
+ if caption:
2823
+ body["text"] = caption
2824
+ if thread_id:
2825
+ body["thread"] = {"name": thread_id}
2826
+
2827
+ # The accompanying messages.create that references the attachment
2828
+ # also needs user auth (the attachmentDataRef is bound to the
2829
+ # uploading principal). messageReplyOption is required for the
2830
+ # thread.name in body to actually be honored — see
2831
+ # _create_message docstring for the API quirk.
2832
+ create_kwargs: Dict[str, Any] = {"parent": chat_id, "body": body}
2833
+ if thread_id:
2834
+ create_kwargs["messageReplyOption"] = (
2835
+ "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
2836
+ )
2837
+
2838
+ def _create_with_attachment() -> Dict[str, Any]:
2839
+ return (
2840
+ chat_api.spaces()
2841
+ .messages()
2842
+ .create(**create_kwargs)
2843
+ .execute()
2844
+ )
2845
+
2846
+ try:
2847
+ resp = await asyncio.to_thread(_create_with_attachment)
2848
+ # Track outbound destination thread (see _create_message
2849
+ # comment for why — same reasoning applies to the
2850
+ # user-OAuth attachment path).
2851
+ resp_thread = (resp.get("thread") or {}).get("name") or ""
2852
+ if chat_id and resp_thread:
2853
+ try:
2854
+ self._thread_count_store.incr(chat_id, resp_thread)
2855
+ except Exception:
2856
+ logger.debug(
2857
+ "[GoogleChat] outbound thread-count incr failed",
2858
+ exc_info=True,
2859
+ )
2860
+ return SendResult(
2861
+ success=True, message_id=resp.get("name"),
2862
+ )
2863
+ except HttpError as exc:
2864
+ return SendResult(
2865
+ success=False, error=_redact_sensitive(str(exc))
2866
+ )
2867
+
2868
+ async def _post_attachment_fallback(
2869
+ self,
2870
+ chat_id: str,
2871
+ path: str,
2872
+ filename: str,
2873
+ caption: Optional[str],
2874
+ thread_id: Optional[str],
2875
+ ) -> SendResult:
2876
+ """Post a text notice when native attachment delivery is unavailable.
2877
+
2878
+ Tells the user that file delivery requires a one-time consent
2879
+ flow (``/setup-files``) and reports the local-host path so the
2880
+ file isn't lost. Returns ``success=False`` so callers know the
2881
+ attachment did not land.
2882
+ """
2883
+ lines = []
2884
+ if caption:
2885
+ lines.append(caption)
2886
+ lines.extend([
2887
+ f"⚠️ No he podido adjuntar **{filename}**.",
2888
+ "Google Chat sólo permite adjuntar archivos cuando el bot tiene "
2889
+ "permiso explícito tuyo (OAuth de usuario). Es un consentimiento "
2890
+ "único que se hace desde este chat.",
2891
+ "**Para activarlo:** envía `/setup-files` y sigue las instrucciones.",
2892
+ f"Mientras tanto el archivo está en el host: `{path}`",
2893
+ ])
2894
+ body: Dict[str, Any] = {"text": "\n".join(lines)}
2895
+ if thread_id:
2896
+ body["thread"] = {"name": thread_id}
2897
+ try:
2898
+ await self._create_message(chat_id, body)
2899
+ except Exception:
2900
+ logger.debug(
2901
+ "[GoogleChat] attachment fallback notice send failed",
2902
+ exc_info=True,
2903
+ )
2904
+ return SendResult(
2905
+ success=False,
2906
+ error="google_chat: native attachment requires user OAuth — "
2907
+ "run /setup-files in chat",
2908
+ )
2909
+
2910
+ # ------------------------------------------------------------------
2911
+ # Metadata
2912
+ # ------------------------------------------------------------------
2913
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
2914
+ """Return {name, type, chat_id} for a space."""
2915
+ try:
2916
+ info = await asyncio.to_thread(
2917
+ lambda: self._chat_api.spaces()
2918
+ .get(name=chat_id)
2919
+ .execute(http=self._new_authed_http())
2920
+ )
2921
+ except HttpError as exc:
2922
+ logger.debug(
2923
+ "[GoogleChat] get_chat_info failed: %s", _redact_sensitive(str(exc))
2924
+ )
2925
+ return {"name": chat_id, "type": "group", "chat_id": chat_id}
2926
+ space_type = (info.get("spaceType") or info.get("type") or "").upper()
2927
+ display = info.get("displayName") or chat_id
2928
+ return {
2929
+ "name": display,
2930
+ "type": "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group",
2931
+ "chat_id": chat_id,
2932
+ }
2933
+
2934
+
2935
+ # ---------------------------------------------------------------------------
2936
+ # Plugin entry point
2937
+ # ---------------------------------------------------------------------------
2938
+
2939
+
2940
+ def _validate_config(config: PlatformConfig) -> bool:
2941
+ """Plugin-side config gate: require both Pub/Sub project and subscription.
2942
+
2943
+ Mirrors the legacy dispatch entry in ``gateway/config.py`` so the
2944
+ registry can decide whether the platform is configured without
2945
+ importing the legacy table.
2946
+ """
2947
+ extra = getattr(config, "extra", {}) or {}
2948
+ return bool(
2949
+ extra.get("project_id") and extra.get("subscription_name")
2950
+ )
2951
+
2952
+
2953
+ def _check_for_registry() -> bool:
2954
+ """``check_fn`` for the platform registry pass — stricter than the
2955
+ deps-only ``check_google_chat_requirements``.
2956
+
2957
+ The registry pass at ``gateway/config.py:_apply_env_overrides`` adds
2958
+ the platform to ``cfg.platforms`` whenever ``check_fn`` returns True.
2959
+ For backward compat with the pre-plugin behavior, we ALSO require
2960
+ the minimum Pub/Sub env vars so an unconfigured user doesn't
2961
+ accidentally see ``google_chat`` enabled. This matches the legacy
2962
+ ``if gc_project and gc_subscription`` gate.
2963
+ """
2964
+ if not check_google_chat_requirements():
2965
+ return False
2966
+ project = (
2967
+ os.getenv("GOOGLE_CHAT_PROJECT_ID")
2968
+ or os.getenv("GOOGLE_CLOUD_PROJECT")
2969
+ )
2970
+ subscription = (
2971
+ os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME")
2972
+ or os.getenv("GOOGLE_CHAT_SUBSCRIPTION")
2973
+ )
2974
+ return bool(project and subscription)
2975
+
2976
+
2977
+ def _is_connected(config: PlatformConfig) -> bool:
2978
+ """``GatewayConfig.get_connected_platforms()`` polls this."""
2979
+ return bool(getattr(config, "enabled", False)) and _validate_config(config)
2980
+
2981
+
2982
+ def _env_enablement() -> Optional[Dict[str, Any]]:
2983
+ """Seed ``PlatformConfig.extra`` from env vars during
2984
+ ``_apply_env_overrides``.
2985
+
2986
+ The registry's env-enablement hook is called BEFORE the adapter is
2987
+ constructed, so ``gateway status`` and ``get_connected_platforms()``
2988
+ reflect env-only configuration without instantiating the Pub/Sub client.
2989
+ Returns ``None`` when the required Pub/Sub project/subscription aren't
2990
+ set; the caller then skips auto-enabling the platform.
2991
+
2992
+ The special ``home_channel`` key in the returned dict is handled by the
2993
+ core hook — it becomes a proper ``HomeChannel`` dataclass on the
2994
+ ``PlatformConfig`` rather than being merged into ``extra``.
2995
+ """
2996
+ project = (
2997
+ os.getenv("GOOGLE_CHAT_PROJECT_ID")
2998
+ or os.getenv("GOOGLE_CLOUD_PROJECT")
2999
+ )
3000
+ subscription = (
3001
+ os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME")
3002
+ or os.getenv("GOOGLE_CHAT_SUBSCRIPTION")
3003
+ )
3004
+ if not (project and subscription):
3005
+ return None
3006
+ seed: Dict[str, Any] = {
3007
+ "project_id": project,
3008
+ "subscription_name": subscription,
3009
+ }
3010
+ sa_json = (
3011
+ os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON")
3012
+ or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
3013
+ )
3014
+ if sa_json:
3015
+ seed["service_account_json"] = sa_json
3016
+ home = os.getenv("GOOGLE_CHAT_HOME_CHANNEL")
3017
+ if home:
3018
+ seed["home_channel"] = {
3019
+ "chat_id": home,
3020
+ "name": os.getenv("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home"),
3021
+ }
3022
+ return seed
3023
+
3024
+
3025
+ def interactive_setup() -> None:
3026
+ """Walk the user through Google Chat configuration via ``hermes setup``.
3027
+
3028
+ The setup wizard at ``hermes_cli/gateway.py`` calls this for plugin
3029
+ platforms instead of using the in-tree ``_PLATFORMS`` data block. The
3030
+ flow mirrors the in-tree built-ins: print the GCP setup instructions,
3031
+ prompt for env vars, persist them to ``~/.hermes/.env`` so the next
3032
+ gateway restart picks them up.
3033
+ """
3034
+ from hermes_cli.cli_output import (
3035
+ print_info,
3036
+ print_success,
3037
+ print_warning,
3038
+ prompt,
3039
+ prompt_yes_no,
3040
+ )
3041
+ from hermes_cli.config import get_env_value, save_env_value
3042
+
3043
+ existing_sub = get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME")
3044
+ if existing_sub:
3045
+ print_info(f"Google Chat: already configured (subscription: {existing_sub})")
3046
+ if not prompt_yes_no("Reconfigure Google Chat?", False):
3047
+ return
3048
+
3049
+ print_info("Google Chat needs a GCP project, a Pub/Sub topic + subscription,")
3050
+ print_info("and a Service Account with Pub/Sub Subscriber on the subscription.")
3051
+ print_info("Walkthrough:")
3052
+ print_info(" 1. Create or select a GCP project; enable Google Chat API + Cloud Pub/Sub API.")
3053
+ print_info(" 2. Create a Service Account (no project-level IAM role needed).")
3054
+ print_info(" 3. Create a Pub/Sub topic (e.g. hermes-chat-events) and a Pull subscription.")
3055
+ print_info(" 4. On the TOPIC: add chat-api-push@system.gserviceaccount.com as Pub/Sub Publisher.")
3056
+ print_info(" 5. On the SUBSCRIPTION: grant your Service Account Pub/Sub Subscriber.")
3057
+ print_info(" 6. Download the Service Account JSON key.")
3058
+ print_info(" 7. Google Chat API console → Configuration: connection = Cloud Pub/Sub,")
3059
+ print_info(" point at the topic, enable 1:1 + group, restrict visibility.")
3060
+ print_info(" 8. Install the bot in a space (fires ADDED_TO_SPACE and resolves its user_id).")
3061
+ print_info("")
3062
+ print_info("Full guide: website/docs/user-guide/messaging/google_chat.md")
3063
+ print_info("")
3064
+
3065
+ project = prompt(
3066
+ "GCP project ID (e.g. my-project)",
3067
+ default=get_env_value("GOOGLE_CHAT_PROJECT_ID") or "",
3068
+ )
3069
+ if not project:
3070
+ print_warning("Project ID is required — skipping Google Chat setup")
3071
+ return
3072
+ save_env_value("GOOGLE_CHAT_PROJECT_ID", project.strip())
3073
+
3074
+ subscription = prompt(
3075
+ "Pub/Sub subscription (projects/<proj>/subscriptions/<sub>)",
3076
+ default=get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") or "",
3077
+ )
3078
+ if not subscription:
3079
+ print_warning("Subscription is required — skipping Google Chat setup")
3080
+ return
3081
+ save_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME", subscription.strip())
3082
+
3083
+ sa_path = prompt(
3084
+ "Path to Service Account JSON (or inline JSON)",
3085
+ default=get_env_value("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or "",
3086
+ password=True,
3087
+ )
3088
+ if sa_path:
3089
+ save_env_value("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", sa_path.strip())
3090
+
3091
+ if prompt_yes_no("Restrict access to specific users? (recommended)", True):
3092
+ allowed = prompt(
3093
+ "Allowed user emails (comma-separated)",
3094
+ default=get_env_value("GOOGLE_CHAT_ALLOWED_USERS") or "",
3095
+ )
3096
+ if allowed:
3097
+ save_env_value("GOOGLE_CHAT_ALLOWED_USERS", allowed.replace(" ", ""))
3098
+ print_success("Allowlist configured")
3099
+ else:
3100
+ save_env_value("GOOGLE_CHAT_ALLOWED_USERS", "")
3101
+ else:
3102
+ save_env_value("GOOGLE_CHAT_ALLOW_ALL_USERS", "true")
3103
+ print_warning("⚠️ Open access — anyone who can DM the bot can command it.")
3104
+
3105
+ home = prompt(
3106
+ "Home space for cron/notification delivery (e.g. spaces/AAAA, or empty)",
3107
+ default=get_env_value("GOOGLE_CHAT_HOME_CHANNEL") or "",
3108
+ )
3109
+ if home:
3110
+ save_env_value("GOOGLE_CHAT_HOME_CHANNEL", home.strip())
3111
+
3112
+ print()
3113
+ print_success("Google Chat configuration saved to ~/.hermes/.env")
3114
+ print_info("Restart the gateway: hermes gateway restart")
3115
+
3116
+
3117
+ # Strict resource-name pattern. ``spaces/<id>`` and ``users/<id>`` must
3118
+ # only contain Google Chat's documented character set; anything else
3119
+ # means a tampered chat_id trying to break out of the REST URL path
3120
+ # (path traversal, ``?`` query injection, ``#`` fragment truncation).
3121
+ _GCHAT_CHAT_ID_RE = re.compile(r"^(?:spaces|users)/[A-Za-z0-9_-]+$")
3122
+
3123
+
3124
+ async def _standalone_send(
3125
+ pconfig,
3126
+ chat_id: str,
3127
+ message: str,
3128
+ *,
3129
+ thread_id: Optional[str] = None,
3130
+ media_files: Optional[List[str]] = None,
3131
+ force_document: bool = False,
3132
+ ) -> Dict[str, Any]:
3133
+ """POST a single Google Chat message via the REST API without the SDK.
3134
+
3135
+ Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
3136
+ runner is not in this process (e.g. ``hermes cron`` running as a
3137
+ separate process from ``hermes gateway``). Without this hook,
3138
+ ``deliver=google_chat`` cron jobs fail with ``No live adapter for
3139
+ platform``.
3140
+
3141
+ Configuration: requires service-account credentials via
3142
+ ``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON``, ``GOOGLE_APPLICATION_CREDENTIALS``,
3143
+ or Application Default Credentials, and a space resource name as
3144
+ ``chat_id`` (e.g. ``spaces/AAAA-BBBB`` or ``users/<id>``).
3145
+
3146
+ Security: ``chat_id`` is validated against the documented Google Chat
3147
+ resource-name character set before substitution into the REST URL so
3148
+ a tampered value cannot path-traverse or query-inject.
3149
+
3150
+ ``media_files`` and ``force_document`` are accepted for signature
3151
+ parity but are not implemented for the standalone path; messages with
3152
+ attachments send as text-only. The live adapter handles attachments.
3153
+ """
3154
+ if not chat_id:
3155
+ return {"error": "Google Chat standalone send: chat_id (space resource) is required"}
3156
+ if not _GCHAT_CHAT_ID_RE.match(chat_id):
3157
+ return {"error": (
3158
+ f"Google Chat standalone send: chat_id {chat_id!r} must match "
3159
+ f"'spaces/<id>' or 'users/<id>' with only [A-Za-z0-9_-] in the id"
3160
+ )}
3161
+ if thread_id is not None and not re.match(r"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$", thread_id):
3162
+ return {"error": (
3163
+ f"Google Chat standalone send: thread_id {thread_id!r} must match "
3164
+ f"'spaces/<id>/threads/<id>'"
3165
+ )}
3166
+
3167
+ extra = getattr(pconfig, "extra", {}) or {}
3168
+ sa_value = (
3169
+ extra.get("service_account_json")
3170
+ or os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON")
3171
+ or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
3172
+ )
3173
+
3174
+ if service_account is None:
3175
+ return {"error": "Google Chat standalone send: google-auth not installed"}
3176
+
3177
+ try:
3178
+ from google.auth.transport.requests import Request as _GoogleAuthRequest
3179
+ except Exception as e:
3180
+ return {"error": f"Google Chat standalone send: google-auth import failed: {e}"}
3181
+
3182
+ try:
3183
+ if sa_value:
3184
+ stripped = sa_value.lstrip()
3185
+ if stripped.startswith("{"):
3186
+ try:
3187
+ info = json.loads(sa_value)
3188
+ except json.JSONDecodeError as exc:
3189
+ return {"error": f"Google Chat standalone send: inline SA JSON is invalid: {exc}"}
3190
+ creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
3191
+ else:
3192
+ if not os.path.exists(sa_value):
3193
+ return {"error": f"Google Chat standalone send: SA JSON file not found at {sa_value}"}
3194
+ try:
3195
+ with open(sa_value, "r", encoding="utf-8") as fh:
3196
+ info = json.load(fh)
3197
+ except json.JSONDecodeError as exc:
3198
+ return {"error": f"Google Chat standalone send: SA JSON file is invalid: {exc}"}
3199
+ creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
3200
+ else:
3201
+ try:
3202
+ import google.auth as _google_auth
3203
+ except ImportError:
3204
+ return {"error": (
3205
+ "Google Chat standalone send: no SA credentials configured "
3206
+ "and google-auth is not installed for ADC fallback"
3207
+ )}
3208
+ try:
3209
+ creds, _project = _google_auth.default(scopes=_CHAT_SCOPES)
3210
+ except Exception as exc:
3211
+ return {"error": (
3212
+ f"Google Chat standalone send: no SA credentials configured "
3213
+ f"and Application Default Credentials are unavailable: {exc}"
3214
+ )}
3215
+ except asyncio.CancelledError:
3216
+ raise
3217
+ except Exception as e:
3218
+ return {"error": f"Google Chat standalone send: credential load failed: {e}"}
3219
+
3220
+ # Bound the synchronous urllib3-backed token refresh so a hung Google
3221
+ # STS endpoint cannot stall the cron scheduler indefinitely.
3222
+ try:
3223
+ await asyncio.wait_for(
3224
+ asyncio.to_thread(creds.refresh, _GoogleAuthRequest()),
3225
+ timeout=10.0,
3226
+ )
3227
+ except asyncio.TimeoutError:
3228
+ return {"error": "Google Chat standalone send: token refresh timed out"}
3229
+ except asyncio.CancelledError:
3230
+ raise
3231
+ except Exception as e:
3232
+ return {"error": f"Google Chat standalone send: token refresh failed: {e}"}
3233
+
3234
+ token = getattr(creds, "token", None)
3235
+ if not token:
3236
+ return {"error": "Google Chat standalone send: refreshed credentials have no token"}
3237
+
3238
+ body: Dict[str, Any] = {"text": message}
3239
+ if thread_id:
3240
+ body["thread"] = {"name": thread_id}
3241
+
3242
+ url = f"https://chat.googleapis.com/v1/{chat_id}/messages"
3243
+ try:
3244
+ import aiohttp as _aiohttp
3245
+ except ImportError:
3246
+ return {"error": "Google Chat standalone send: aiohttp not installed"}
3247
+
3248
+ try:
3249
+ async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0)) as session:
3250
+ async with session.post(
3251
+ url,
3252
+ json=body,
3253
+ headers={
3254
+ "Authorization": f"Bearer {token}",
3255
+ "Content-Type": "application/json",
3256
+ },
3257
+ ) as resp:
3258
+ if resp.status >= 400:
3259
+ text = await resp.text()
3260
+ return {"error": (
3261
+ f"Google Chat standalone send: API returned "
3262
+ f"{resp.status}: {text[:300]}"
3263
+ )}
3264
+ payload = await resp.json()
3265
+ return {
3266
+ "success": True,
3267
+ "message_id": payload.get("name"),
3268
+ }
3269
+ except asyncio.CancelledError:
3270
+ raise
3271
+ except Exception as e:
3272
+ logger.debug("Google Chat standalone send raised", exc_info=True)
3273
+ return {"error": f"Google Chat standalone send failed: {e}"}
3274
+
3275
+
3276
+ def register(ctx) -> None:
3277
+ """Plugin entry point — called by the Hermes plugin system at startup.
3278
+
3279
+ Registers the Google Chat adapter under the ``google_chat`` name.
3280
+ The gateway's ``_create_adapter`` consults the platform registry
3281
+ BEFORE its built-in if/elif chain, so this registration is what
3282
+ drives adapter creation at runtime.
3283
+ """
3284
+ ctx.register_platform(
3285
+ name="google_chat",
3286
+ label="Google Chat",
3287
+ adapter_factory=lambda cfg: GoogleChatAdapter(cfg),
3288
+ check_fn=_check_for_registry,
3289
+ validate_config=_validate_config,
3290
+ is_connected=_is_connected,
3291
+ required_env=[
3292
+ "GOOGLE_CHAT_PROJECT_ID",
3293
+ "GOOGLE_CHAT_SUBSCRIPTION_NAME",
3294
+ "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
3295
+ ],
3296
+ install_hint="pip install 'hermes-agent[google_chat]'",
3297
+ setup_fn=interactive_setup,
3298
+ # Env-driven auto-configuration — the core env-populator hook calls
3299
+ # this during ``_apply_env_overrides`` and seeds
3300
+ # ``PlatformConfig.extra`` + home_channel from env vars. Without this
3301
+ # the adapter would still work on explicit config.yaml entries, but
3302
+ # env-only setup (GOOGLE_CHAT_PROJECT_ID/_SUBSCRIPTION_NAME/...) would
3303
+ # not flow through to ``gateway status`` or ``get_connected_platforms``.
3304
+ env_enablement_fn=_env_enablement,
3305
+ # Cron home-channel delivery support. Lets ``deliver=google_chat``
3306
+ # cron jobs route to the configured home space without editing
3307
+ # cron/scheduler.py's hardcoded sets.
3308
+ cron_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL",
3309
+ # Out-of-process cron delivery via the Chat REST API. Without this
3310
+ # hook, deliver=google_chat cron jobs fail with "No live adapter"
3311
+ # when cron runs separately from the gateway.
3312
+ standalone_sender_fn=_standalone_send,
3313
+ # Auth env vars for _is_user_authorized() integration.
3314
+ allowed_users_env="GOOGLE_CHAT_ALLOWED_USERS",
3315
+ allow_all_env="GOOGLE_CHAT_ALLOW_ALL_USERS",
3316
+ # Chat caps text messages at 4096 chars; we leave margin to fit
3317
+ # the "Hermes is thinking..." marker patches and edit overhead.
3318
+ max_message_length=4000,
3319
+ emoji="💬",
3320
+ allow_update_command=True,
3321
+ platform_hint=(
3322
+ "You are on Google Chat. Limited markdown subset is rendered: "
3323
+ "*bold*, _italic_, ~strike~, `code`. No headings or lists. "
3324
+ "Message size limit: 4000 characters; longer responses are split "
3325
+ "across multiple messages. You are in a space (DM or group). "
3326
+ "Images render inline; audio, video, and document attachments "
3327
+ "render as download cards (no native voice/video UI). To send "
3328
+ "files, include MEDIA:/absolute/path/to/file in your response. "
3329
+ "Native file attachments require the user to run /setup-files "
3330
+ "once in their own DM — until they do, file requests fall back "
3331
+ "to a text notice with the host path. Do NOT generate interactive "
3332
+ "Card v2 buttons — Google Chat interactivity is not yet supported "
3333
+ "by this gateway; ask for typed confirmations instead. While you "
3334
+ "are generating a response, a 'Hermes is thinking…' marker message "
3335
+ "appears in the space and is deleted once your response is ready. "
3336
+ "You do NOT have access to Google Chat-specific APIs — you cannot "
3337
+ "search space history, list space members, or manage spaces. Do "
3338
+ "not promise to perform these actions; explain that you can only "
3339
+ "read messages sent directly to you and respond in the same "
3340
+ "space/thread."
3341
+ ),
3342
+ )
3343
+