@clawpump/claw-agent 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1212) hide show
  1. package/agent/.dockerignore +67 -0
  2. package/agent/.envrc +1 -1
  3. package/agent/.gitattributes +8 -0
  4. package/agent/AGENTS.md +216 -4
  5. package/agent/CONTRIBUTING.md +46 -8
  6. package/agent/Dockerfile +78 -35
  7. package/agent/MANIFEST.in +2 -0
  8. package/agent/README.md +12 -5
  9. package/agent/README.ur-pk.md +261 -0
  10. package/agent/README.zh-CN.md +11 -8
  11. package/agent/SECURITY.md +5 -4
  12. package/agent/acp_adapter/provenance.py +127 -0
  13. package/agent/acp_adapter/server.py +112 -5
  14. package/agent/acp_adapter/session.py +1 -6
  15. package/agent/acp_registry/agent.json +2 -2
  16. package/agent/agent/account_usage.py +313 -1
  17. package/agent/agent/agent_init.py +140 -37
  18. package/agent/agent/agent_runtime_helpers.py +342 -83
  19. package/agent/agent/anthropic_adapter.py +320 -33
  20. package/agent/agent/auxiliary_client.py +525 -105
  21. package/agent/agent/background_review.py +157 -19
  22. package/agent/agent/bedrock_adapter.py +71 -6
  23. package/agent/agent/billing_view.py +295 -0
  24. package/agent/agent/chat_completion_helpers.py +229 -4
  25. package/agent/agent/codex_responses_adapter.py +86 -10
  26. package/agent/agent/codex_runtime.py +153 -1
  27. package/agent/agent/coding_context.py +738 -0
  28. package/agent/agent/context_compressor.py +392 -44
  29. package/agent/agent/context_references.py +34 -1
  30. package/agent/agent/conversation_compression.py +159 -22
  31. package/agent/agent/conversation_loop.py +643 -908
  32. package/agent/agent/copilot_acp_client.py +4 -11
  33. package/agent/agent/credential_pool.py +5 -3
  34. package/agent/agent/credits_tracker.py +794 -0
  35. package/agent/agent/curator.py +91 -18
  36. package/agent/agent/curator_backup.py +26 -10
  37. package/agent/agent/display.py +42 -1
  38. package/agent/agent/error_classifier.py +52 -3
  39. package/agent/agent/errors.py +3 -0
  40. package/agent/agent/file_safety.py +0 -17
  41. package/agent/agent/gemini_native_adapter.py +31 -1
  42. package/agent/agent/i18n.py +48 -4
  43. package/agent/agent/image_gen_provider.py +74 -5
  44. package/agent/agent/image_routing.py +29 -0
  45. package/agent/agent/insights.py +8 -17
  46. package/agent/agent/lsp/install.py +3 -0
  47. package/agent/agent/memory_manager.py +326 -31
  48. package/agent/agent/message_content.py +50 -0
  49. package/agent/agent/model_metadata.py +214 -3
  50. package/agent/agent/moonshot_schema.py +8 -1
  51. package/agent/agent/onboarding.py +60 -0
  52. package/agent/agent/prompt_builder.py +327 -37
  53. package/agent/agent/redact.py +1 -0
  54. package/agent/agent/runtime_cwd.py +34 -5
  55. package/agent/agent/secret_scope.py +205 -0
  56. package/agent/agent/secret_sources/bitwarden.py +34 -2
  57. package/agent/agent/skill_commands.py +90 -1
  58. package/agent/agent/skill_preprocessing.py +1 -0
  59. package/agent/agent/skill_utils.py +209 -36
  60. package/agent/agent/ssl_guard.py +94 -0
  61. package/agent/agent/system_prompt.py +133 -5
  62. package/agent/agent/tool_executor.py +496 -70
  63. package/agent/agent/transports/anthropic.py +83 -21
  64. package/agent/agent/transports/chat_completions.py +94 -5
  65. package/agent/agent/transports/codex.py +67 -2
  66. package/agent/agent/transports/codex_app_server.py +1 -0
  67. package/agent/agent/transports/codex_app_server_session.py +30 -0
  68. package/agent/agent/transports/types.py +12 -0
  69. package/agent/agent/turn_context.py +408 -0
  70. package/agent/agent/turn_finalizer.py +428 -0
  71. package/agent/agent/turn_retry_state.py +68 -0
  72. package/agent/agent/usage_pricing.py +3 -0
  73. package/agent/apps/bootstrap-installer/package.json +6 -5
  74. package/agent/apps/bootstrap-installer/src/routes/failure.tsx +12 -5
  75. package/agent/apps/bootstrap-installer/src/routes/progress.tsx +1 -3
  76. package/agent/apps/bootstrap-installer/src/store.ts +3 -2
  77. package/agent/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +172 -7
  78. package/agent/apps/bootstrap-installer/src-tauri/src/events.rs +14 -1
  79. package/agent/apps/bootstrap-installer/src-tauri/src/paths.rs +29 -0
  80. package/agent/apps/bootstrap-installer/src-tauri/src/powershell.rs +93 -3
  81. package/agent/apps/bootstrap-installer/src-tauri/src/update.rs +695 -39
  82. package/agent/apps/bootstrap-installer/tsconfig.json +3 -4
  83. package/agent/apps/desktop/DESIGN.md +167 -0
  84. package/agent/apps/desktop/README.md +20 -16
  85. package/agent/apps/desktop/assets/icon.icns +0 -0
  86. package/agent/apps/desktop/assets/icon.ico +0 -0
  87. package/agent/apps/desktop/assets/icon.png +0 -0
  88. package/agent/apps/desktop/electron/backend-env.cjs +112 -0
  89. package/agent/apps/desktop/electron/backend-env.test.cjs +111 -0
  90. package/agent/apps/desktop/electron/backend-probes.test.cjs +3 -1
  91. package/agent/apps/desktop/electron/backend-ready.cjs +66 -0
  92. package/agent/apps/desktop/electron/bootstrap-platform.cjs +52 -0
  93. package/agent/apps/desktop/electron/bootstrap-platform.test.cjs +59 -1
  94. package/agent/apps/desktop/electron/bootstrap-runner.cjs +176 -38
  95. package/agent/apps/desktop/electron/bootstrap-runner.test.cjs +112 -1
  96. package/agent/apps/desktop/electron/connection-config.cjs +288 -0
  97. package/agent/apps/desktop/electron/connection-config.test.cjs +396 -0
  98. package/agent/apps/desktop/electron/dashboard-token.cjs +99 -0
  99. package/agent/apps/desktop/electron/dashboard-token.test.cjs +142 -0
  100. package/agent/apps/desktop/electron/desktop-uninstall.cjs +232 -0
  101. package/agent/apps/desktop/electron/desktop-uninstall.test.cjs +246 -0
  102. package/agent/apps/desktop/electron/entitlements.mac.inherit.plist +2 -0
  103. package/agent/apps/desktop/electron/fs-read-dir.cjs +109 -0
  104. package/agent/apps/desktop/electron/fs-read-dir.test.cjs +364 -0
  105. package/agent/apps/desktop/electron/gateway-ws-probe.cjs +188 -0
  106. package/agent/apps/desktop/electron/gateway-ws-probe.test.cjs +122 -0
  107. package/agent/apps/desktop/electron/git-root.cjs +54 -0
  108. package/agent/apps/desktop/electron/git-root.test.cjs +40 -0
  109. package/agent/apps/desktop/electron/git-worktrees.cjs +174 -0
  110. package/agent/apps/desktop/electron/hardening.cjs +123 -28
  111. package/agent/apps/desktop/electron/hardening.test.cjs +163 -0
  112. package/agent/apps/desktop/electron/main.cjs +3121 -331
  113. package/agent/apps/desktop/electron/oauth-net-request.cjs +20 -0
  114. package/agent/apps/desktop/electron/oauth-net-request.test.cjs +34 -0
  115. package/agent/apps/desktop/electron/preload.cjs +52 -2
  116. package/agent/apps/desktop/electron/session-windows.cjs +124 -0
  117. package/agent/apps/desktop/electron/session-windows.test.cjs +199 -0
  118. package/agent/apps/desktop/electron/update-rebuild.cjs +29 -0
  119. package/agent/apps/desktop/electron/update-rebuild.test.cjs +55 -0
  120. package/agent/apps/desktop/electron/update-remote.cjs +56 -0
  121. package/agent/apps/desktop/electron/update-remote.test.cjs +78 -0
  122. package/agent/apps/desktop/electron/vscode-marketplace.cjs +331 -0
  123. package/agent/apps/desktop/electron/vscode-marketplace.test.cjs +113 -0
  124. package/agent/apps/desktop/electron/windows-child-process.test.cjs +57 -0
  125. package/agent/apps/desktop/electron/windows-user-env.cjs +76 -0
  126. package/agent/apps/desktop/electron/windows-user-env.test.cjs +90 -0
  127. package/agent/apps/desktop/electron/workspace-cwd.cjs +38 -0
  128. package/agent/apps/desktop/electron/workspace-cwd.test.cjs +45 -0
  129. package/agent/apps/desktop/eslint.config.mjs +0 -3
  130. package/agent/apps/desktop/index.html +27 -2
  131. package/agent/apps/desktop/package.json +31 -11
  132. package/agent/apps/desktop/pr-assets/session-source-folders.png +0 -0
  133. package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
  134. package/agent/apps/desktop/public/nous-girl.jpg +0 -0
  135. package/agent/apps/desktop/scripts/assert-dist-built.cjs +70 -0
  136. package/agent/apps/desktop/scripts/assert-dist-built.test.cjs +84 -0
  137. package/agent/apps/desktop/scripts/before-pack.cjs +78 -0
  138. package/agent/apps/desktop/scripts/before-pack.test.cjs +53 -0
  139. package/agent/apps/desktop/scripts/diag-scroll-reset.mjs +229 -0
  140. package/agent/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +64 -0
  141. package/agent/apps/desktop/scripts/run-electron-builder.cjs +57 -0
  142. package/agent/apps/desktop/src/app/agents/index.tsx +53 -45
  143. package/agent/apps/desktop/src/app/artifacts/index.tsx +102 -83
  144. package/agent/apps/desktop/src/app/chat/chat-drop-overlay.tsx +29 -8
  145. package/agent/apps/desktop/src/app/chat/chat-swap-overlay.tsx +47 -0
  146. package/agent/apps/desktop/src/app/chat/composer/attachments.tsx +81 -45
  147. package/agent/apps/desktop/src/app/chat/composer/completion-drawer.tsx +13 -24
  148. package/agent/apps/desktop/src/app/chat/composer/context-menu.tsx +138 -88
  149. package/agent/apps/desktop/src/app/chat/composer/controls.tsx +138 -90
  150. package/agent/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
  151. package/agent/apps/desktop/src/app/chat/composer/focus.ts +32 -0
  152. package/agent/apps/desktop/src/app/chat/composer/help-hint.tsx +38 -25
  153. package/agent/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +7 -0
  154. package/agent/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +22 -12
  155. package/agent/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +142 -14
  156. package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +14 -11
  157. package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +9 -6
  158. package/agent/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
  159. package/agent/apps/desktop/src/app/chat/composer/index.tsx +930 -180
  160. package/agent/apps/desktop/src/app/chat/composer/inline-refs.ts +136 -32
  161. package/agent/apps/desktop/src/app/chat/composer/model-pill.tsx +86 -0
  162. package/agent/apps/desktop/src/app/chat/composer/queue-panel.tsx +54 -75
  163. package/agent/apps/desktop/src/app/chat/composer/rich-editor.test.ts +117 -1
  164. package/agent/apps/desktop/src/app/chat/composer/rich-editor.ts +117 -6
  165. package/agent/apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
  166. package/agent/apps/desktop/src/app/chat/composer/status-stack/index.tsx +202 -0
  167. package/agent/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +155 -0
  168. package/agent/apps/desktop/src/app/chat/composer/text-utils.test.ts +104 -0
  169. package/agent/apps/desktop/src/app/chat/composer/text-utils.ts +37 -9
  170. package/agent/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +50 -0
  171. package/agent/apps/desktop/src/app/chat/composer/trigger-popover.tsx +105 -40
  172. package/agent/apps/desktop/src/app/chat/composer/types.ts +5 -0
  173. package/agent/apps/desktop/src/app/chat/composer/url-dialog.tsx +11 -15
  174. package/agent/apps/desktop/src/app/chat/composer/voice-activity.tsx +8 -4
  175. package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
  176. package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +70 -16
  177. package/agent/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +52 -16
  178. package/agent/apps/desktop/src/app/chat/index.tsx +234 -81
  179. package/agent/apps/desktop/src/app/chat/perf-probe.tsx +69 -21
  180. package/agent/apps/desktop/src/app/chat/right-rail/preview-console.tsx +44 -40
  181. package/agent/apps/desktop/src/app/chat/right-rail/preview-file.tsx +71 -25
  182. package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +40 -1
  183. package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +55 -53
  184. package/agent/apps/desktop/src/app/chat/right-rail/preview.tsx +35 -17
  185. package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
  186. package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +74 -0
  187. package/agent/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +356 -0
  188. package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +1189 -364
  189. package/agent/apps/desktop/src/app/chat/sidebar/load-more-row.tsx +30 -0
  190. package/agent/apps/desktop/src/app/chat/sidebar/order.test.ts +21 -0
  191. package/agent/apps/desktop/src/app/chat/sidebar/order.ts +17 -0
  192. package/agent/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +524 -0
  193. package/agent/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +80 -45
  194. package/agent/apps/desktop/src/app/chat/sidebar/session-row.tsx +120 -25
  195. package/agent/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +7 -13
  196. package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
  197. package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.ts +326 -0
  198. package/agent/apps/desktop/src/app/chat/thread-loading.ts +7 -2
  199. package/agent/apps/desktop/src/app/command-center/index.tsx +320 -581
  200. package/agent/apps/desktop/src/app/command-palette/index.tsx +681 -0
  201. package/agent/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +157 -0
  202. package/agent/apps/desktop/src/app/cron/index.tsx +392 -324
  203. package/agent/apps/desktop/src/app/cron/job-state.ts +29 -0
  204. package/agent/apps/desktop/src/app/desktop-controller.tsx +618 -123
  205. package/agent/apps/desktop/src/app/floating-hud.ts +22 -0
  206. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
  207. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +260 -14
  208. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +48 -4
  209. package/agent/apps/desktop/src/app/hooks/use-keybinds.ts +270 -0
  210. package/agent/apps/desktop/src/app/hooks/use-refresh-hotkey.ts +45 -0
  211. package/agent/apps/desktop/src/app/layout-constants.ts +19 -0
  212. package/agent/apps/desktop/src/app/messaging/index.tsx +136 -241
  213. package/agent/apps/desktop/src/app/messaging/platform-icon.tsx +95 -0
  214. package/agent/apps/desktop/src/app/model-visibility-overlay.tsx +31 -0
  215. package/agent/apps/desktop/src/app/overlays/overlay-search-input.tsx +18 -62
  216. package/agent/apps/desktop/src/app/overlays/overlay-split-layout.tsx +59 -7
  217. package/agent/apps/desktop/src/app/overlays/overlay-view.tsx +9 -5
  218. package/agent/apps/desktop/src/app/page-search-shell.tsx +42 -20
  219. package/agent/apps/desktop/src/app/profiles/create-profile-dialog.tsx +165 -0
  220. package/agent/apps/desktop/src/app/profiles/delete-profile-dialog.tsx +65 -0
  221. package/agent/apps/desktop/src/app/profiles/index.tsx +174 -199
  222. package/agent/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +125 -0
  223. package/agent/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts +27 -0
  224. package/agent/apps/desktop/src/app/right-sidebar/files/ipc.test.ts +100 -0
  225. package/agent/apps/desktop/src/app/right-sidebar/files/ipc.ts +12 -18
  226. package/agent/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +177 -0
  227. package/agent/apps/desktop/src/app/right-sidebar/files/tree.tsx +35 -21
  228. package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +75 -3
  229. package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +152 -5
  230. package/agent/apps/desktop/src/app/right-sidebar/index.test.tsx +75 -0
  231. package/agent/apps/desktop/src/app/right-sidebar/index.tsx +166 -129
  232. package/agent/apps/desktop/src/app/right-sidebar/store.ts +19 -4
  233. package/agent/apps/desktop/src/app/right-sidebar/terminal/buffer.ts +65 -0
  234. package/agent/apps/desktop/src/app/right-sidebar/terminal/index.tsx +29 -34
  235. package/agent/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +18 -6
  236. package/agent/apps/desktop/src/app/right-sidebar/terminal/selection.ts +93 -32
  237. package/agent/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +381 -119
  238. package/agent/apps/desktop/src/app/routes.ts +9 -0
  239. package/agent/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +17 -7
  240. package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +365 -47
  241. package/agent/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +198 -0
  242. package/agent/apps/desktop/src/app/session/hooks/use-model-controls.ts +70 -34
  243. package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +1061 -0
  244. package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +1143 -165
  245. package/agent/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +341 -2
  246. package/agent/apps/desktop/src/app/session/hooks/use-route-resume.ts +176 -5
  247. package/agent/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +259 -0
  248. package/agent/apps/desktop/src/app/session/hooks/use-session-actions.ts +452 -149
  249. package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +327 -0
  250. package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +133 -4
  251. package/agent/apps/desktop/src/app/session-picker-overlay.tsx +32 -0
  252. package/agent/apps/desktop/src/app/session-switcher.tsx +107 -0
  253. package/agent/apps/desktop/src/app/settings/about-settings.tsx +45 -36
  254. package/agent/apps/desktop/src/app/settings/appearance-settings.tsx +243 -162
  255. package/agent/apps/desktop/src/app/settings/config-settings.tsx +86 -66
  256. package/agent/apps/desktop/src/app/settings/constants.ts +459 -122
  257. package/agent/apps/desktop/src/app/settings/credential-key-ui.tsx +373 -0
  258. package/agent/apps/desktop/src/app/settings/env-credentials.tsx +198 -0
  259. package/agent/apps/desktop/src/app/settings/env-var-actions-menu.tsx +136 -0
  260. package/agent/apps/desktop/src/app/settings/field-copy.ts +56 -0
  261. package/agent/apps/desktop/src/app/settings/gateway-settings.tsx +385 -72
  262. package/agent/apps/desktop/src/app/settings/helpers.test.ts +156 -1
  263. package/agent/apps/desktop/src/app/settings/helpers.ts +30 -2
  264. package/agent/apps/desktop/src/app/settings/index.tsx +118 -84
  265. package/agent/apps/desktop/src/app/settings/keys-settings.tsx +62 -419
  266. package/agent/apps/desktop/src/app/settings/mcp-settings.tsx +65 -60
  267. package/agent/apps/desktop/src/app/settings/model-settings.test.tsx +129 -5
  268. package/agent/apps/desktop/src/app/settings/model-settings.tsx +370 -65
  269. package/agent/apps/desktop/src/app/settings/notifications-settings.tsx +150 -0
  270. package/agent/apps/desktop/src/app/settings/primitives.tsx +5 -11
  271. package/agent/apps/desktop/src/app/settings/provider-config-panel.test.tsx +142 -0
  272. package/agent/apps/desktop/src/app/settings/provider-config-panel.tsx +182 -0
  273. package/agent/apps/desktop/src/app/settings/providers-settings.test.tsx +171 -0
  274. package/agent/apps/desktop/src/app/settings/providers-settings.tsx +471 -0
  275. package/agent/apps/desktop/src/app/settings/sessions-settings.tsx +183 -71
  276. package/agent/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +135 -1
  277. package/agent/apps/desktop/src/app/settings/toolset-config-panel.tsx +180 -57
  278. package/agent/apps/desktop/src/app/settings/types.ts +9 -6
  279. package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +185 -0
  280. package/agent/apps/desktop/src/app/settings/use-deep-link-highlight.ts +60 -0
  281. package/agent/apps/desktop/src/app/shell/app-shell.tsx +59 -13
  282. package/agent/apps/desktop/src/app/shell/gateway-menu-panel.tsx +37 -32
  283. package/agent/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +6 -3
  284. package/agent/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +212 -53
  285. package/agent/apps/desktop/src/app/shell/keybind-panel.tsx +215 -0
  286. package/agent/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +84 -0
  287. package/agent/apps/desktop/src/app/shell/model-edit-submenu.tsx +244 -0
  288. package/agent/apps/desktop/src/app/shell/model-menu-panel.tsx +392 -0
  289. package/agent/apps/desktop/src/app/shell/statusbar-controls.tsx +23 -33
  290. package/agent/apps/desktop/src/app/shell/titlebar-controls.tsx +79 -95
  291. package/agent/apps/desktop/src/app/shell/titlebar.ts +8 -2
  292. package/agent/apps/desktop/src/app/skills/index.test.tsx +11 -0
  293. package/agent/apps/desktop/src/app/skills/index.tsx +79 -64
  294. package/agent/apps/desktop/src/app/types.ts +85 -0
  295. package/agent/apps/desktop/src/app/updates-overlay.tsx +110 -105
  296. package/agent/apps/desktop/src/components/assistant-ui/ansi-text.tsx +34 -0
  297. package/agent/apps/desktop/src/components/assistant-ui/block-direction.test.tsx +129 -0
  298. package/agent/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +102 -81
  299. package/agent/apps/desktop/src/components/assistant-ui/directive-text.tsx +92 -15
  300. package/agent/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +38 -0
  301. package/agent/apps/desktop/src/components/assistant-ui/markdown-text.tsx +304 -45
  302. package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
  303. package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +48 -0
  304. package/agent/apps/desktop/src/components/assistant-ui/streaming.test.tsx +142 -90
  305. package/agent/apps/desktop/src/components/assistant-ui/thread-list.tsx +337 -0
  306. package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +667 -190
  307. package/agent/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +299 -0
  308. package/agent/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +133 -0
  309. package/agent/apps/desktop/src/components/assistant-ui/tool-approval.tsx +239 -0
  310. package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +31 -0
  311. package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +152 -134
  312. package/agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +142 -150
  313. package/agent/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx +14 -12
  314. package/agent/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
  315. package/agent/apps/desktop/src/components/assistant-ui/user-message-text.tsx +152 -0
  316. package/agent/apps/desktop/src/components/boot-failure-overlay.tsx +150 -33
  317. package/agent/apps/desktop/src/components/boot-failure-reauth.test.ts +100 -0
  318. package/agent/apps/desktop/src/components/boot-failure-reauth.ts +81 -0
  319. package/agent/apps/desktop/src/components/brand-mark.tsx +19 -0
  320. package/agent/apps/desktop/src/components/chat/code-card.tsx +1 -1
  321. package/agent/apps/desktop/src/components/chat/composer-dock.ts +31 -0
  322. package/agent/apps/desktop/src/components/chat/diff-lines.tsx +1 -1
  323. package/agent/apps/desktop/src/components/chat/disclosure-row.tsx +13 -3
  324. package/agent/apps/desktop/src/components/chat/expandable-block.tsx +52 -0
  325. package/agent/apps/desktop/src/components/chat/generated-image-result.tsx +174 -0
  326. package/agent/apps/desktop/src/components/chat/image-generation-placeholder.tsx +70 -37
  327. package/agent/apps/desktop/src/components/chat/intro.tsx +8 -7
  328. package/agent/apps/desktop/src/components/chat/preview-attachment.tsx +4 -2
  329. package/agent/apps/desktop/src/components/chat/shiki-highlighter.test.ts +37 -0
  330. package/agent/apps/desktop/src/components/chat/shiki-highlighter.tsx +96 -22
  331. package/agent/apps/desktop/src/components/chat/status-row.tsx +70 -0
  332. package/agent/apps/desktop/src/components/chat/status-section.tsx +42 -0
  333. package/agent/apps/desktop/src/components/chat/terminal-output.tsx +54 -0
  334. package/agent/apps/desktop/src/components/chat/zoomable-image.tsx +70 -109
  335. package/agent/apps/desktop/src/components/desktop-install-overlay.tsx +154 -84
  336. package/agent/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +38 -8
  337. package/agent/apps/desktop/src/components/desktop-onboarding-overlay.tsx +789 -233
  338. package/agent/apps/desktop/src/components/error-boundary.tsx +77 -0
  339. package/agent/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +144 -0
  340. package/agent/apps/desktop/src/components/gateway-connecting-overlay.tsx +7 -1
  341. package/agent/apps/desktop/src/components/haptics-provider.tsx +24 -0
  342. package/agent/apps/desktop/src/components/language-switcher.test.tsx +53 -0
  343. package/agent/apps/desktop/src/components/language-switcher.tsx +175 -0
  344. package/agent/apps/desktop/src/components/model-picker.tsx +42 -40
  345. package/agent/apps/desktop/src/components/model-visibility-dialog.tsx +166 -0
  346. package/agent/apps/desktop/src/components/notifications.tsx +48 -27
  347. package/agent/apps/desktop/src/components/pane-shell/index.ts +1 -1
  348. package/agent/apps/desktop/src/components/pane-shell/pane-shell.tsx +146 -9
  349. package/agent/apps/desktop/src/components/prompt-overlays.tsx +234 -0
  350. package/agent/apps/desktop/src/components/session-picker.tsx +108 -0
  351. package/agent/apps/desktop/src/components/ui/action-status.tsx +25 -0
  352. package/agent/apps/desktop/src/components/ui/badge.tsx +35 -0
  353. package/agent/apps/desktop/src/components/ui/button.tsx +37 -13
  354. package/agent/apps/desktop/src/components/ui/confirm-dialog.tsx +109 -0
  355. package/agent/apps/desktop/src/components/ui/control.ts +25 -0
  356. package/agent/apps/desktop/src/components/ui/copy-button.test.tsx +36 -0
  357. package/agent/apps/desktop/src/components/ui/copy-button.tsx +38 -27
  358. package/agent/apps/desktop/src/components/ui/dialog.tsx +39 -11
  359. package/agent/apps/desktop/src/components/ui/dropdown-menu.tsx +98 -24
  360. package/agent/apps/desktop/src/components/ui/error-state.tsx +50 -0
  361. package/agent/apps/desktop/src/components/ui/fade-text.tsx +9 -2
  362. package/agent/apps/desktop/src/components/ui/{braille-spinner.tsx → glyph-spinner.tsx} +15 -13
  363. package/agent/apps/desktop/src/components/ui/input.tsx +5 -2
  364. package/agent/apps/desktop/src/components/ui/kbd.tsx +83 -12
  365. package/agent/apps/desktop/src/components/ui/log-view.tsx +19 -0
  366. package/agent/apps/desktop/src/components/ui/pagination.tsx +12 -5
  367. package/agent/apps/desktop/src/components/ui/popover.tsx +44 -0
  368. package/agent/apps/desktop/src/components/ui/search-field.tsx +80 -0
  369. package/agent/apps/desktop/src/components/ui/segmented-control.tsx +51 -0
  370. package/agent/apps/desktop/src/components/ui/select.tsx +10 -3
  371. package/agent/apps/desktop/src/components/ui/sheet.tsx +8 -2
  372. package/agent/apps/desktop/src/components/ui/sidebar.tsx +18 -25
  373. package/agent/apps/desktop/src/components/ui/switch.tsx +38 -15
  374. package/agent/apps/desktop/src/components/ui/textarea.tsx +4 -11
  375. package/agent/apps/desktop/src/components/ui/tool-icon.tsx +65 -0
  376. package/agent/apps/desktop/src/components/ui/tooltip.tsx +31 -4
  377. package/agent/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
  378. package/agent/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
  379. package/agent/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
  380. package/agent/apps/desktop/src/global.d.ts +181 -4
  381. package/agent/apps/desktop/src/hermes.test.ts +60 -0
  382. package/agent/apps/desktop/src/hermes.ts +190 -13
  383. package/agent/apps/desktop/src/hooks/use-image-download.ts +85 -0
  384. package/agent/apps/desktop/src/hooks/use-resize-observer.ts +13 -4
  385. package/agent/apps/desktop/src/hooks/use-worktree-info.ts +68 -0
  386. package/agent/apps/desktop/src/i18n/catalog.ts +12 -0
  387. package/agent/apps/desktop/src/i18n/context.test.tsx +232 -0
  388. package/agent/apps/desktop/src/i18n/context.tsx +183 -0
  389. package/agent/apps/desktop/src/i18n/define-locale.ts +41 -0
  390. package/agent/apps/desktop/src/i18n/en.ts +1921 -0
  391. package/agent/apps/desktop/src/i18n/index.ts +20 -0
  392. package/agent/apps/desktop/src/i18n/ja.ts +2053 -0
  393. package/agent/apps/desktop/src/i18n/languages.test.ts +43 -0
  394. package/agent/apps/desktop/src/i18n/languages.ts +86 -0
  395. package/agent/apps/desktop/src/i18n/runtime.test.ts +75 -0
  396. package/agent/apps/desktop/src/i18n/runtime.ts +53 -0
  397. package/agent/apps/desktop/src/i18n/types.ts +1559 -0
  398. package/agent/apps/desktop/src/i18n/zh-hant.ts +1992 -0
  399. package/agent/apps/desktop/src/i18n/zh.ts +2099 -0
  400. package/agent/apps/desktop/src/lib/ansi.test.ts +123 -0
  401. package/agent/apps/desktop/src/lib/ansi.ts +186 -0
  402. package/agent/apps/desktop/src/lib/chat-messages.test.ts +79 -0
  403. package/agent/apps/desktop/src/lib/chat-messages.ts +68 -29
  404. package/agent/apps/desktop/src/lib/chat-runtime.test.ts +65 -1
  405. package/agent/apps/desktop/src/lib/chat-runtime.ts +39 -3
  406. package/agent/apps/desktop/src/lib/completion-sound.ts +519 -0
  407. package/agent/apps/desktop/src/lib/desktop-fs.test.ts +116 -0
  408. package/agent/apps/desktop/src/lib/desktop-fs.ts +113 -0
  409. package/agent/apps/desktop/src/lib/desktop-slash-commands.test.ts +89 -6
  410. package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +270 -131
  411. package/agent/apps/desktop/src/lib/external-link.test.tsx +27 -0
  412. package/agent/apps/desktop/src/lib/external-link.tsx +9 -2
  413. package/agent/apps/desktop/src/lib/gateway-events.test.ts +27 -0
  414. package/agent/apps/desktop/src/lib/gateway-events.ts +16 -0
  415. package/agent/apps/desktop/src/lib/gateway-ws-url.test.ts +78 -0
  416. package/agent/apps/desktop/src/lib/gateway-ws-url.ts +91 -0
  417. package/agent/apps/desktop/src/lib/generated-images.test.ts +97 -0
  418. package/agent/apps/desktop/src/lib/generated-images.ts +116 -0
  419. package/agent/apps/desktop/src/lib/haptics.ts +17 -0
  420. package/agent/apps/desktop/src/lib/icons.ts +10 -2
  421. package/agent/apps/desktop/src/lib/keybinds/actions.ts +137 -0
  422. package/agent/apps/desktop/src/lib/keybinds/combo.test.ts +86 -0
  423. package/agent/apps/desktop/src/lib/keybinds/combo.ts +195 -0
  424. package/agent/apps/desktop/src/lib/local-preview.ts +23 -2
  425. package/agent/apps/desktop/src/lib/markdown-preprocess.ts +20 -7
  426. package/agent/apps/desktop/src/lib/media.remote.test.ts +90 -0
  427. package/agent/apps/desktop/src/lib/media.ts +40 -1
  428. package/agent/apps/desktop/src/lib/model-status-label.test.ts +59 -0
  429. package/agent/apps/desktop/src/lib/model-status-label.ts +122 -0
  430. package/agent/apps/desktop/src/lib/mutable-ref.ts +6 -0
  431. package/agent/apps/desktop/src/lib/profile-color.ts +58 -0
  432. package/agent/apps/desktop/src/lib/query-client.ts +13 -0
  433. package/agent/apps/desktop/src/lib/remend-tail.test.ts +105 -0
  434. package/agent/apps/desktop/src/lib/remend-tail.ts +108 -0
  435. package/agent/apps/desktop/src/lib/session-export.ts +6 -3
  436. package/agent/apps/desktop/src/lib/session-ids.test.ts +44 -0
  437. package/agent/apps/desktop/src/lib/session-ids.ts +26 -0
  438. package/agent/apps/desktop/src/lib/session-search.test.ts +66 -0
  439. package/agent/apps/desktop/src/lib/session-search.ts +21 -0
  440. package/agent/apps/desktop/src/lib/session-source.ts +126 -0
  441. package/agent/apps/desktop/src/lib/storage.test.ts +25 -0
  442. package/agent/apps/desktop/src/lib/storage.ts +35 -1
  443. package/agent/apps/desktop/src/lib/todos.test.ts +46 -1
  444. package/agent/apps/desktop/src/lib/todos.ts +37 -0
  445. package/agent/apps/desktop/src/lib/tool-result-summary.ts +5 -1
  446. package/agent/apps/desktop/src/lib/update-copy.test.ts +38 -0
  447. package/agent/apps/desktop/src/lib/update-copy.ts +44 -0
  448. package/agent/apps/desktop/src/lib/use-enter-animation.ts +2 -2
  449. package/agent/apps/desktop/src/lib/yolo-session.ts +50 -0
  450. package/agent/apps/desktop/src/main.tsx +19 -19
  451. package/agent/apps/desktop/src/store/boot.ts +4 -3
  452. package/agent/apps/desktop/src/store/clarify.test.ts +81 -0
  453. package/agent/apps/desktop/src/store/clarify.ts +50 -13
  454. package/agent/apps/desktop/src/store/command-palette.ts +20 -0
  455. package/agent/apps/desktop/src/store/compaction.test.ts +53 -0
  456. package/agent/apps/desktop/src/store/compaction.ts +38 -0
  457. package/agent/apps/desktop/src/store/completion-sound.ts +32 -0
  458. package/agent/apps/desktop/src/store/composer-input-history.test.ts +147 -0
  459. package/agent/apps/desktop/src/store/composer-input-history.ts +158 -0
  460. package/agent/apps/desktop/src/store/composer-queue.test.ts +68 -0
  461. package/agent/apps/desktop/src/store/composer-queue.ts +76 -0
  462. package/agent/apps/desktop/src/store/composer-status.test.ts +99 -0
  463. package/agent/apps/desktop/src/store/composer-status.ts +277 -0
  464. package/agent/apps/desktop/src/store/composer.test.ts +106 -0
  465. package/agent/apps/desktop/src/store/composer.ts +116 -0
  466. package/agent/apps/desktop/src/store/cron.ts +19 -0
  467. package/agent/apps/desktop/src/store/gateway.ts +280 -6
  468. package/agent/apps/desktop/src/store/keybinds.ts +143 -0
  469. package/agent/apps/desktop/src/store/layout.ts +107 -9
  470. package/agent/apps/desktop/src/store/model-presets.test.ts +51 -0
  471. package/agent/apps/desktop/src/store/model-presets.ts +86 -0
  472. package/agent/apps/desktop/src/store/model-visibility.test.ts +99 -0
  473. package/agent/apps/desktop/src/store/model-visibility.ts +161 -0
  474. package/agent/apps/desktop/src/store/native-notifications.test.ts +192 -0
  475. package/agent/apps/desktop/src/store/native-notifications.ts +203 -0
  476. package/agent/apps/desktop/src/store/notifications.ts +10 -7
  477. package/agent/apps/desktop/src/store/onboarding.test.ts +271 -1
  478. package/agent/apps/desktop/src/store/onboarding.ts +268 -38
  479. package/agent/apps/desktop/src/store/preview.ts +10 -1
  480. package/agent/apps/desktop/src/store/profile.test.ts +89 -0
  481. package/agent/apps/desktop/src/store/profile.ts +395 -0
  482. package/agent/apps/desktop/src/store/prompts.test.ts +127 -0
  483. package/agent/apps/desktop/src/store/prompts.ts +117 -0
  484. package/agent/apps/desktop/src/store/session-switcher.test.ts +115 -0
  485. package/agent/apps/desktop/src/store/session-switcher.ts +128 -0
  486. package/agent/apps/desktop/src/store/session-sync.ts +25 -0
  487. package/agent/apps/desktop/src/store/session.test.ts +268 -2
  488. package/agent/apps/desktop/src/store/session.ts +392 -18
  489. package/agent/apps/desktop/src/store/subagents.ts +3 -0
  490. package/agent/apps/desktop/src/store/system-actions.ts +48 -0
  491. package/agent/apps/desktop/src/store/thread-scroll.ts +58 -5
  492. package/agent/apps/desktop/src/store/todos.test.ts +47 -0
  493. package/agent/apps/desktop/src/store/todos.ts +64 -0
  494. package/agent/apps/desktop/src/store/tool-dismiss.ts +45 -0
  495. package/agent/apps/desktop/src/store/translucency.ts +38 -0
  496. package/agent/apps/desktop/src/store/updates.test.ts +187 -2
  497. package/agent/apps/desktop/src/store/updates.ts +268 -18
  498. package/agent/apps/desktop/src/store/windows.test.ts +143 -0
  499. package/agent/apps/desktop/src/store/windows.ts +115 -0
  500. package/agent/apps/desktop/src/styles.css +510 -119
  501. package/agent/apps/desktop/src/themes/color.ts +142 -0
  502. package/agent/apps/desktop/src/themes/context.tsx +128 -75
  503. package/agent/apps/desktop/src/themes/install.test.ts +119 -0
  504. package/agent/apps/desktop/src/themes/install.ts +95 -0
  505. package/agent/apps/desktop/src/themes/presets.test.ts +33 -0
  506. package/agent/apps/desktop/src/themes/presets.ts +13 -4
  507. package/agent/apps/desktop/src/themes/profile-theme.test.ts +41 -0
  508. package/agent/apps/desktop/src/themes/types.ts +35 -0
  509. package/agent/apps/desktop/src/themes/user-themes.test.ts +63 -0
  510. package/agent/apps/desktop/src/themes/user-themes.ts +122 -0
  511. package/agent/apps/desktop/src/themes/vscode.test.ts +171 -0
  512. package/agent/apps/desktop/src/themes/vscode.ts +343 -0
  513. package/agent/apps/desktop/src/types/hermes.ts +138 -1
  514. package/agent/apps/desktop/tsconfig.json +2 -2
  515. package/agent/apps/desktop/vite.config.ts +18 -0
  516. package/agent/apps/shared/package.json +1 -1
  517. package/agent/apps/shared/src/json-rpc-gateway.ts +63 -2
  518. package/agent/apps/shared/tsconfig.json +2 -2
  519. package/agent/cli-config.yaml.example +78 -1
  520. package/agent/cli.py +2177 -3162
  521. package/agent/cron/blueprint_catalog.py +713 -0
  522. package/agent/cron/jobs.py +226 -110
  523. package/agent/cron/scheduler.py +468 -193
  524. package/agent/cron/scheduler_provider.py +177 -0
  525. package/agent/cron/scripts/__init__.py +1 -0
  526. package/agent/cron/scripts/classify_items.py +226 -0
  527. package/agent/cron/suggestion_catalog.py +154 -0
  528. package/agent/cron/suggestions.py +257 -0
  529. package/agent/docs/chronos-managed-cron-contract.md +196 -0
  530. package/agent/docs/design/profile-builder.md +146 -0
  531. package/agent/docs/middleware/README.md +260 -0
  532. package/agent/docs/observability/README.md +316 -0
  533. package/agent/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md +240 -0
  534. package/agent/docs/rca-ssl-cacert-post-git-pull.md +54 -0
  535. package/agent/docs/relay-connector-contract.md +285 -0
  536. package/agent/gateway/authz_mixin.py +536 -0
  537. package/agent/gateway/channel_directory.py +65 -3
  538. package/agent/gateway/config.py +222 -12
  539. package/agent/gateway/display_config.py +10 -0
  540. package/agent/gateway/hooks.py +17 -0
  541. package/agent/gateway/kanban_watchers.py +1146 -0
  542. package/agent/gateway/message_timestamps.py +166 -0
  543. package/agent/gateway/platforms/ADDING_A_PLATFORM.md +29 -0
  544. package/agent/gateway/platforms/api_server.py +216 -38
  545. package/agent/gateway/platforms/base.py +210 -58
  546. package/agent/gateway/platforms/email.py +122 -12
  547. package/agent/gateway/platforms/feishu.py +80 -11
  548. package/agent/gateway/platforms/feishu_meeting_invite.py +212 -0
  549. package/agent/gateway/platforms/matrix.py +1498 -297
  550. package/agent/gateway/platforms/qqbot/adapter.py +6 -0
  551. package/agent/gateway/platforms/signal.py +8 -0
  552. package/agent/gateway/platforms/slack.py +308 -12
  553. package/agent/gateway/platforms/telegram.py +831 -24
  554. package/agent/gateway/platforms/webhook.py +109 -21
  555. package/agent/gateway/platforms/weixin.py +113 -2
  556. package/agent/gateway/platforms/whatsapp.py +94 -288
  557. package/agent/gateway/platforms/whatsapp_cloud.py +1956 -0
  558. package/agent/gateway/platforms/whatsapp_common.py +367 -0
  559. package/agent/gateway/platforms/yuanbao.py +608 -191
  560. package/agent/gateway/platforms/yuanbao_proto.py +232 -23
  561. package/agent/gateway/relay/__init__.py +375 -0
  562. package/agent/gateway/relay/adapter.py +222 -0
  563. package/agent/gateway/relay/auth.py +168 -0
  564. package/agent/gateway/relay/descriptor.py +118 -0
  565. package/agent/gateway/relay/transport.py +101 -0
  566. package/agent/gateway/relay/ws_transport.py +327 -0
  567. package/agent/gateway/response_filters.py +53 -0
  568. package/agent/gateway/rich_sent_store.py +80 -0
  569. package/agent/gateway/run.py +2940 -5001
  570. package/agent/gateway/session.py +109 -8
  571. package/agent/gateway/session_context.py +22 -4
  572. package/agent/gateway/slash_commands.py +3854 -0
  573. package/agent/gateway/status.py +141 -21
  574. package/agent/gateway/stream_consumer.py +288 -31
  575. package/agent/hermes-already-has-routines.md +1 -1
  576. package/agent/hermes_cli/__init__.py +62 -17
  577. package/agent/hermes_cli/_parser.py +30 -0
  578. package/agent/hermes_cli/_subprocess_compat.py +61 -0
  579. package/agent/hermes_cli/active_sessions.py +320 -0
  580. package/agent/hermes_cli/auth.py +707 -59
  581. package/agent/hermes_cli/auth_commands.py +39 -22
  582. package/agent/hermes_cli/backup.py +109 -7
  583. package/agent/hermes_cli/banner.py +88 -0
  584. package/agent/hermes_cli/blueprint_cmd.py +318 -0
  585. package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
  586. package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
  587. package/agent/hermes_cli/commands.py +215 -91
  588. package/agent/hermes_cli/config.py +967 -130
  589. package/agent/hermes_cli/container_boot.py +76 -11
  590. package/agent/hermes_cli/cron.py +5 -11
  591. package/agent/hermes_cli/curator.py +21 -0
  592. package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
  593. package/agent/hermes_cli/dashboard_auth/base.py +62 -0
  594. package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
  595. package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
  596. package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
  597. package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
  598. package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
  599. package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
  600. package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
  601. package/agent/hermes_cli/dashboard_register.py +427 -0
  602. package/agent/hermes_cli/debug.py +155 -50
  603. package/agent/hermes_cli/doctor.py +255 -14
  604. package/agent/hermes_cli/dump.py +60 -6
  605. package/agent/hermes_cli/env_loader.py +33 -0
  606. package/agent/hermes_cli/gateway.py +755 -103
  607. package/agent/hermes_cli/gateway_enroll.py +250 -0
  608. package/agent/hermes_cli/gateway_windows.py +254 -11
  609. package/agent/hermes_cli/gui_uninstall.py +285 -0
  610. package/agent/hermes_cli/inventory.py +105 -4
  611. package/agent/hermes_cli/kanban.py +58 -71
  612. package/agent/hermes_cli/kanban_db.py +391 -14
  613. package/agent/hermes_cli/kanban_decompose.py +2 -2
  614. package/agent/hermes_cli/kanban_specify.py +3 -1
  615. package/agent/hermes_cli/logs.py +2 -0
  616. package/agent/hermes_cli/main.py +2889 -5287
  617. package/agent/hermes_cli/managed_scope.py +214 -0
  618. package/agent/hermes_cli/managed_uv.py +254 -0
  619. package/agent/hermes_cli/mcp_catalog.py +6 -3
  620. package/agent/hermes_cli/mcp_config.py +145 -21
  621. package/agent/hermes_cli/mcp_security.py +96 -0
  622. package/agent/hermes_cli/mcp_startup.py +32 -3
  623. package/agent/hermes_cli/memory_providers.py +149 -0
  624. package/agent/hermes_cli/memory_setup.py +97 -42
  625. package/agent/hermes_cli/middleware.py +313 -0
  626. package/agent/hermes_cli/model_catalog.py +31 -0
  627. package/agent/hermes_cli/model_cost_guard.py +134 -0
  628. package/agent/hermes_cli/model_normalize.py +2 -1
  629. package/agent/hermes_cli/model_setup_flows.py +2759 -0
  630. package/agent/hermes_cli/model_switch.py +242 -27
  631. package/agent/hermes_cli/models.py +284 -44
  632. package/agent/hermes_cli/nous_account.py +33 -6
  633. package/agent/hermes_cli/nous_billing.py +406 -0
  634. package/agent/hermes_cli/nous_subscription.py +202 -5
  635. package/agent/hermes_cli/platforms.py +1 -0
  636. package/agent/hermes_cli/plugins.py +218 -18
  637. package/agent/hermes_cli/plugins_cmd.py +249 -105
  638. package/agent/hermes_cli/portal_cli.py +56 -16
  639. package/agent/hermes_cli/profile_distribution.py +6 -1
  640. package/agent/hermes_cli/profiles.py +283 -32
  641. package/agent/hermes_cli/provider_catalog.py +170 -0
  642. package/agent/hermes_cli/providers.py +4 -1
  643. package/agent/hermes_cli/pty_bridge.py +53 -4
  644. package/agent/hermes_cli/runtime_provider.py +216 -34
  645. package/agent/hermes_cli/secret_prompt.py +4 -4
  646. package/agent/hermes_cli/secrets_cli.py +24 -0
  647. package/agent/hermes_cli/send_cmd.py +28 -2
  648. package/agent/hermes_cli/service_manager.py +166 -19
  649. package/agent/hermes_cli/session_listing.py +97 -0
  650. package/agent/hermes_cli/setup.py +158 -94
  651. package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
  652. package/agent/hermes_cli/skills_config.py +8 -2
  653. package/agent/hermes_cli/skills_hub.py +149 -7
  654. package/agent/hermes_cli/status.py +2 -2
  655. package/agent/hermes_cli/subcommands/__init__.py +18 -0
  656. package/agent/hermes_cli/subcommands/_shared.py +29 -0
  657. package/agent/hermes_cli/subcommands/acp.py +52 -0
  658. package/agent/hermes_cli/subcommands/auth.py +109 -0
  659. package/agent/hermes_cli/subcommands/backup.py +38 -0
  660. package/agent/hermes_cli/subcommands/claw.py +92 -0
  661. package/agent/hermes_cli/subcommands/config.py +49 -0
  662. package/agent/hermes_cli/subcommands/cron.py +163 -0
  663. package/agent/hermes_cli/subcommands/dashboard.py +143 -0
  664. package/agent/hermes_cli/subcommands/debug.py +77 -0
  665. package/agent/hermes_cli/subcommands/doctor.py +35 -0
  666. package/agent/hermes_cli/subcommands/dump.py +28 -0
  667. package/agent/hermes_cli/subcommands/gateway.py +332 -0
  668. package/agent/hermes_cli/subcommands/gui.py +63 -0
  669. package/agent/hermes_cli/subcommands/hooks.py +77 -0
  670. package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
  671. package/agent/hermes_cli/subcommands/insights.py +25 -0
  672. package/agent/hermes_cli/subcommands/login.py +78 -0
  673. package/agent/hermes_cli/subcommands/logout.py +28 -0
  674. package/agent/hermes_cli/subcommands/logs.py +78 -0
  675. package/agent/hermes_cli/subcommands/mcp.py +108 -0
  676. package/agent/hermes_cli/subcommands/memory.py +53 -0
  677. package/agent/hermes_cli/subcommands/model.py +72 -0
  678. package/agent/hermes_cli/subcommands/pairing.py +36 -0
  679. package/agent/hermes_cli/subcommands/plugins.py +94 -0
  680. package/agent/hermes_cli/subcommands/postinstall.py +23 -0
  681. package/agent/hermes_cli/subcommands/profile.py +203 -0
  682. package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
  683. package/agent/hermes_cli/subcommands/security.py +62 -0
  684. package/agent/hermes_cli/subcommands/setup.py +58 -0
  685. package/agent/hermes_cli/subcommands/skills.py +298 -0
  686. package/agent/hermes_cli/subcommands/slack.py +60 -0
  687. package/agent/hermes_cli/subcommands/status.py +28 -0
  688. package/agent/hermes_cli/subcommands/tools.py +95 -0
  689. package/agent/hermes_cli/subcommands/uninstall.py +41 -0
  690. package/agent/hermes_cli/subcommands/update.py +70 -0
  691. package/agent/hermes_cli/subcommands/version.py +18 -0
  692. package/agent/hermes_cli/subcommands/webhook.py +76 -0
  693. package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
  694. package/agent/hermes_cli/suggestions_cmd.py +153 -0
  695. package/agent/hermes_cli/telegram_managed_bot.py +358 -0
  696. package/agent/hermes_cli/tips.py +3 -4
  697. package/agent/hermes_cli/tools_config.py +155 -28
  698. package/agent/hermes_cli/uninstall.py +231 -35
  699. package/agent/hermes_cli/web_server.py +6190 -973
  700. package/agent/hermes_cli/win_pty_bridge.py +179 -0
  701. package/agent/hermes_cli/write_approval_commands.py +209 -0
  702. package/agent/hermes_constants.py +164 -33
  703. package/agent/hermes_logging.py +74 -2
  704. package/agent/hermes_state.py +919 -106
  705. package/agent/hermes_time.py +20 -0
  706. package/agent/locales/af.yaml +23 -0
  707. package/agent/locales/de.yaml +23 -0
  708. package/agent/locales/en.yaml +20 -0
  709. package/agent/locales/es.yaml +23 -0
  710. package/agent/locales/fr.yaml +23 -0
  711. package/agent/locales/ga.yaml +23 -0
  712. package/agent/locales/hu.yaml +23 -0
  713. package/agent/locales/it.yaml +23 -0
  714. package/agent/locales/ja.yaml +23 -0
  715. package/agent/locales/ko.yaml +23 -0
  716. package/agent/locales/pt.yaml +23 -0
  717. package/agent/locales/ru.yaml +23 -0
  718. package/agent/locales/tr.yaml +23 -0
  719. package/agent/locales/uk.yaml +23 -0
  720. package/agent/locales/zh-hant.yaml +23 -0
  721. package/agent/locales/zh.yaml +23 -0
  722. package/agent/model_tools.py +204 -40
  723. package/agent/optional-mcps/clawpump/manifest.yaml +4 -2
  724. package/agent/optional-mcps/clawpump-stdio/manifest.yaml +2 -0
  725. package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
  726. package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
  727. package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
  728. package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
  729. package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
  730. package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
  731. package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
  732. package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
  733. package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
  734. package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
  735. package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
  736. package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
  737. package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
  738. package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
  739. package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
  740. package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
  741. package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
  742. package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
  743. package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
  744. package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
  745. package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
  746. package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
  747. package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
  748. package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
  749. package/agent/optional-skills/security/1password/SKILL.md +1 -1
  750. package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
  751. package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
  752. package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
  753. package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
  754. package/agent/package-lock.json +4082 -7907
  755. package/agent/package.json +18 -3
  756. package/agent/plugins/browser/firecrawl/provider.py +4 -1
  757. package/agent/plugins/cron/__init__.py +344 -0
  758. package/agent/plugins/cron/chronos/__init__.py +241 -0
  759. package/agent/plugins/cron/chronos/_nas_client.py +123 -0
  760. package/agent/plugins/cron/chronos/plugin.yaml +9 -0
  761. package/agent/plugins/cron/chronos/verify.py +103 -0
  762. package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
  763. package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
  764. package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
  765. package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
  766. package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
  767. package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
  768. package/agent/plugins/google_meet/audio_bridge.py +4 -0
  769. package/agent/plugins/google_meet/meet_bot.py +7 -1
  770. package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
  771. package/agent/plugins/image_gen/fal/__init__.py +35 -6
  772. package/agent/plugins/image_gen/krea/__init__.py +56 -13
  773. package/agent/plugins/image_gen/openai/__init__.py +122 -24
  774. package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
  775. package/agent/plugins/image_gen/xai/__init__.py +92 -12
  776. package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
  777. package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
  778. package/agent/plugins/memory/__init__.py +48 -5
  779. package/agent/plugins/memory/byterover/__init__.py +1 -0
  780. package/agent/plugins/memory/hindsight/README.md +1 -1
  781. package/agent/plugins/memory/hindsight/__init__.py +138 -24
  782. package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
  783. package/agent/plugins/memory/honcho/README.md +13 -10
  784. package/agent/plugins/memory/honcho/cli.py +247 -122
  785. package/agent/plugins/memory/honcho/client.py +112 -102
  786. package/agent/plugins/memory/openviking/README.md +12 -1
  787. package/agent/plugins/memory/openviking/__init__.py +2281 -107
  788. package/agent/plugins/memory/openviking/plugin.yaml +1 -2
  789. package/agent/plugins/memory/supermemory/README.md +22 -10
  790. package/agent/plugins/memory/supermemory/__init__.py +142 -37
  791. package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
  792. package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
  793. package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
  794. package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
  795. package/agent/plugins/model-providers/custom/__init__.py +8 -2
  796. package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
  797. package/agent/plugins/model-providers/minimax/__init__.py +60 -8
  798. package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
  799. package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
  800. package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
  801. package/agent/plugins/model-providers/zai/__init__.py +1 -0
  802. package/agent/plugins/observability/langfuse/__init__.py +147 -14
  803. package/agent/plugins/observability/nemo_relay/README.md +559 -0
  804. package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
  805. package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
  806. package/agent/plugins/platforms/discord/adapter.py +932 -61
  807. package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
  808. package/agent/plugins/platforms/google_chat/adapter.py +9 -3
  809. package/agent/plugins/platforms/google_chat/oauth.py +1 -1
  810. package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
  811. package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
  812. package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
  813. package/agent/plugins/platforms/irc/adapter.py +4 -1
  814. package/agent/plugins/platforms/line/adapter.py +16 -1
  815. package/agent/plugins/platforms/mattermost/adapter.py +100 -24
  816. package/agent/plugins/platforms/photon/README.md +179 -0
  817. package/agent/plugins/platforms/photon/__init__.py +4 -0
  818. package/agent/plugins/platforms/photon/adapter.py +1586 -0
  819. package/agent/plugins/platforms/photon/auth.py +1046 -0
  820. package/agent/plugins/platforms/photon/cli.py +439 -0
  821. package/agent/plugins/platforms/photon/plugin.yaml +88 -0
  822. package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
  823. package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
  824. package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
  825. package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
  826. package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
  827. package/agent/plugins/platforms/raft/__init__.py +3 -0
  828. package/agent/plugins/platforms/raft/adapter.py +774 -0
  829. package/agent/plugins/platforms/raft/plugin.yaml +19 -0
  830. package/agent/plugins/platforms/simplex/adapter.py +777 -220
  831. package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
  832. package/agent/plugins/platforms/teams/adapter.py +175 -5
  833. package/agent/plugins/plugin_utils.py +135 -0
  834. package/agent/plugins/video_gen/fal/__init__.py +10 -3
  835. package/agent/plugins/web/searxng/provider.py +15 -2
  836. package/agent/plugins/web/xai/provider.py +2 -2
  837. package/agent/providers/base.py +22 -3
  838. package/agent/pyproject.toml +115 -21
  839. package/agent/run_agent.py +733 -39
  840. package/agent/scripts/build_skills_index.py +51 -19
  841. package/agent/scripts/check_subprocess_stdin.py +177 -0
  842. package/agent/scripts/contributor_audit.py +2 -0
  843. package/agent/scripts/docker_config_migrate.py +67 -0
  844. package/agent/scripts/install.cmd +3 -3
  845. package/agent/scripts/install.ps1 +580 -154
  846. package/agent/scripts/install.sh +402 -185
  847. package/agent/scripts/lib/node-bootstrap.sh +39 -4
  848. package/agent/scripts/release.py +183 -0
  849. package/agent/scripts/run_tests.sh +1 -0
  850. package/agent/scripts/run_tests_parallel.py +18 -23
  851. package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
  852. package/agent/setup.py +59 -0
  853. package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
  854. package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
  855. package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
  856. package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
  857. package/agent/skills/clawpump/SKILL.md +4 -1
  858. package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
  859. package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
  860. package/agent/skills/github/github-auth/SKILL.md +2 -2
  861. package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
  862. package/agent/skills/github/github-code-review/SKILL.md +2 -2
  863. package/agent/skills/github/github-issues/SKILL.md +2 -2
  864. package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
  865. package/agent/skills/github/github-repo-management/SKILL.md +2 -2
  866. package/agent/skills/media/gif-search/SKILL.md +1 -1
  867. package/agent/skills/media/youtube-content/SKILL.md +10 -7
  868. package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
  869. package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
  870. package/agent/skills/productivity/airtable/SKILL.md +2 -2
  871. package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
  872. package/agent/skills/productivity/notion/SKILL.md +2 -2
  873. package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
  874. package/agent/skills/research/llm-wiki/SKILL.md +1 -1
  875. package/agent/skills/social-media/xurl/SKILL.md +9 -0
  876. package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
  877. package/agent/skills/software-development/plan/SKILL.md +285 -5
  878. package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
  879. package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
  880. package/agent/skills/software-development/spike/SKILL.md +2 -2
  881. package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
  882. package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
  883. package/agent/tools/approval.py +302 -4
  884. package/agent/tools/async_delegation.py +386 -0
  885. package/agent/tools/blueprints.py +325 -0
  886. package/agent/tools/browser_cdp_tool.py +3 -3
  887. package/agent/tools/browser_tool.py +34 -6
  888. package/agent/tools/checkpoint_manager.py +31 -1
  889. package/agent/tools/clarify_tool.py +55 -5
  890. package/agent/tools/code_execution_tool.py +31 -14
  891. package/agent/tools/computer_use/cua_backend.py +81 -3
  892. package/agent/tools/computer_use/tool.py +79 -5
  893. package/agent/tools/computer_use/vision_routing.py +55 -3
  894. package/agent/tools/credential_files.py +31 -12
  895. package/agent/tools/cronjob_tools.py +30 -20
  896. package/agent/tools/delegate_tool.py +356 -31
  897. package/agent/tools/env_probe.py +1 -0
  898. package/agent/tools/environments/docker.py +163 -8
  899. package/agent/tools/environments/file_sync.py +2 -1
  900. package/agent/tools/environments/local.py +74 -23
  901. package/agent/tools/environments/singularity.py +4 -1
  902. package/agent/tools/environments/ssh.py +78 -11
  903. package/agent/tools/file_operations.py +277 -41
  904. package/agent/tools/file_tools.py +166 -28
  905. package/agent/tools/image_generation_tool.py +515 -29
  906. package/agent/tools/kanban_tools.py +99 -0
  907. package/agent/tools/lazy_deps.py +33 -2
  908. package/agent/tools/mcp_oauth.py +5 -5
  909. package/agent/tools/mcp_oauth_manager.py +7 -5
  910. package/agent/tools/mcp_tool.py +840 -33
  911. package/agent/tools/memory_tool.py +335 -38
  912. package/agent/tools/osv_check.py +15 -1
  913. package/agent/tools/process_registry.py +155 -11
  914. package/agent/tools/read_extract.py +248 -0
  915. package/agent/tools/read_terminal_tool.py +93 -0
  916. package/agent/tools/schema_sanitizer.py +38 -0
  917. package/agent/tools/send_message_tool.py +163 -49
  918. package/agent/tools/session_search_tool.py +189 -7
  919. package/agent/tools/skill_manager_tool.py +202 -3
  920. package/agent/tools/skill_usage.py +52 -4
  921. package/agent/tools/skills_hub.py +184 -44
  922. package/agent/tools/skills_sync.py +232 -5
  923. package/agent/tools/skills_tool.py +125 -11
  924. package/agent/tools/terminal_tool.py +148 -26
  925. package/agent/tools/tirith_security.py +2 -0
  926. package/agent/tools/todo_tool.py +32 -1
  927. package/agent/tools/transcription_tools.py +13 -5
  928. package/agent/tools/tts_tool.py +332 -38
  929. package/agent/tools/url_safety.py +52 -1
  930. package/agent/tools/vision_tools.py +124 -39
  931. package/agent/tools/voice_mode.py +4 -3
  932. package/agent/tools/web_tools.py +45 -15
  933. package/agent/tools/write_approval.py +493 -0
  934. package/agent/toolsets.py +34 -10
  935. package/agent/trajectory_compressor.py +81 -10
  936. package/agent/tui_gateway/entry.py +43 -6
  937. package/agent/tui_gateway/server.py +3335 -330
  938. package/agent/tui_gateway/slash_worker.py +61 -0
  939. package/agent/tui_gateway/ws.py +67 -9
  940. package/agent/ui-tui/eslint.config.mjs +0 -4
  941. package/agent/ui-tui/package.json +6 -6
  942. package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
  943. package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
  944. package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
  945. package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
  946. package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
  947. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
  948. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
  949. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
  950. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
  951. package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
  952. package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
  953. package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
  954. package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
  955. package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
  956. package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
  957. package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
  958. package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
  959. package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
  960. package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
  961. package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
  962. package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
  963. package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
  964. package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
  965. package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
  966. package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
  967. package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
  968. package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
  969. package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
  970. package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
  971. package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
  972. package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
  973. package/agent/ui-tui/src/app/interfaces.ts +64 -1
  974. package/agent/ui-tui/src/app/overlayStore.ts +18 -2
  975. package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
  976. package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
  977. package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
  978. package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
  979. package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
  980. package/agent/ui-tui/src/app/slash/registry.ts +4 -0
  981. package/agent/ui-tui/src/app/turnController.ts +145 -2
  982. package/agent/ui-tui/src/app/uiStore.ts +2 -0
  983. package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
  984. package/agent/ui-tui/src/app/useMainApp.ts +54 -8
  985. package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
  986. package/agent/ui-tui/src/app/useSubmission.ts +23 -31
  987. package/agent/ui-tui/src/components/appChrome.tsx +112 -5
  988. package/agent/ui-tui/src/components/appLayout.tsx +9 -0
  989. package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
  990. package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
  991. package/agent/ui-tui/src/components/branding.tsx +15 -3
  992. package/agent/ui-tui/src/components/messageLine.tsx +25 -3
  993. package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
  994. package/agent/ui-tui/src/components/prompts.tsx +31 -17
  995. package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
  996. package/agent/ui-tui/src/components/textInput.tsx +16 -0
  997. package/agent/ui-tui/src/config/env.ts +12 -0
  998. package/agent/ui-tui/src/config/limits.ts +13 -0
  999. package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
  1000. package/agent/ui-tui/src/domain/paths.ts +24 -0
  1001. package/agent/ui-tui/src/domain/slash.ts +40 -0
  1002. package/agent/ui-tui/src/entry.tsx +35 -4
  1003. package/agent/ui-tui/src/gatewayClient.ts +22 -10
  1004. package/agent/ui-tui/src/gatewayTypes.ts +130 -1
  1005. package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
  1006. package/agent/ui-tui/src/lib/memory.test.ts +162 -0
  1007. package/agent/ui-tui/src/lib/memory.ts +60 -1
  1008. package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
  1009. package/agent/ui-tui/src/lib/osc52.ts +1 -1
  1010. package/agent/ui-tui/src/lib/text.test.ts +32 -1
  1011. package/agent/ui-tui/src/lib/text.ts +29 -2
  1012. package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
  1013. package/agent/ui-tui/src/types.ts +5 -0
  1014. package/agent/ui-tui/tsconfig.build.json +0 -1
  1015. package/agent/ui-tui/tsconfig.json +2 -1
  1016. package/agent/utils.py +66 -2
  1017. package/agent/uv.lock +300 -684
  1018. package/agent/web/index.html +2 -2
  1019. package/agent/web/package.json +11 -6
  1020. package/agent/web/public/claw-bg.webp +0 -0
  1021. package/agent/web/public/claw-logo.webp +0 -0
  1022. package/agent/web/src/App.tsx +138 -48
  1023. package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
  1024. package/agent/web/src/components/Backdrop.tsx +15 -0
  1025. package/agent/web/src/components/ChatSessionList.tsx +260 -0
  1026. package/agent/web/src/components/ChatSidebar.tsx +262 -78
  1027. package/agent/web/src/components/ConfirmDialog.tsx +122 -0
  1028. package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
  1029. package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
  1030. package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
  1031. package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
  1032. package/agent/web/src/components/ReasoningPicker.tsx +167 -0
  1033. package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
  1034. package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
  1035. package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
  1036. package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
  1037. package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
  1038. package/agent/web/src/contexts/SystemActions.tsx +6 -8
  1039. package/agent/web/src/contexts/profile-context.ts +19 -0
  1040. package/agent/web/src/contexts/useProfileScope.ts +6 -0
  1041. package/agent/web/src/i18n/af.ts +5 -4
  1042. package/agent/web/src/i18n/de.ts +5 -4
  1043. package/agent/web/src/i18n/en.ts +58 -4
  1044. package/agent/web/src/i18n/es.ts +5 -3
  1045. package/agent/web/src/i18n/fr.ts +5 -3
  1046. package/agent/web/src/i18n/ga.ts +5 -4
  1047. package/agent/web/src/i18n/hu.ts +5 -4
  1048. package/agent/web/src/i18n/it.ts +5 -4
  1049. package/agent/web/src/i18n/ja.ts +5 -4
  1050. package/agent/web/src/i18n/ko.ts +5 -4
  1051. package/agent/web/src/i18n/pt.ts +5 -3
  1052. package/agent/web/src/i18n/ru.ts +5 -4
  1053. package/agent/web/src/i18n/tr.ts +5 -4
  1054. package/agent/web/src/i18n/types.ts +59 -1
  1055. package/agent/web/src/i18n/uk.ts +5 -3
  1056. package/agent/web/src/i18n/zh-hant.ts +5 -4
  1057. package/agent/web/src/i18n/zh.ts +5 -4
  1058. package/agent/web/src/index.css +2 -2
  1059. package/agent/web/src/lib/api.ts +819 -52
  1060. package/agent/web/src/lib/dashboard-flags.ts +16 -7
  1061. package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
  1062. package/agent/web/src/lib/reasoning-effort.ts +36 -0
  1063. package/agent/web/src/lib/session-refresh.test.ts +21 -0
  1064. package/agent/web/src/lib/session-refresh.ts +26 -0
  1065. package/agent/web/src/pages/ChannelsPage.tsx +529 -68
  1066. package/agent/web/src/pages/ChatPage.tsx +249 -56
  1067. package/agent/web/src/pages/ConfigPage.tsx +11 -1
  1068. package/agent/web/src/pages/CronPage.tsx +219 -31
  1069. package/agent/web/src/pages/EnvPage.tsx +25 -6
  1070. package/agent/web/src/pages/FilesPage.tsx +525 -0
  1071. package/agent/web/src/pages/McpPage.tsx +80 -3
  1072. package/agent/web/src/pages/ModelsPage.tsx +97 -12
  1073. package/agent/web/src/pages/PluginsPage.tsx +1 -1
  1074. package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
  1075. package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
  1076. package/agent/web/src/pages/SessionsPage.tsx +144 -13
  1077. package/agent/web/src/pages/SkillsPage.tsx +851 -70
  1078. package/agent/web/src/pages/SystemPage.tsx +340 -4
  1079. package/agent/web/src/pages/WalletPage.tsx +401 -0
  1080. package/agent/web/src/pages/WebhooksPage.tsx +145 -15
  1081. package/agent/web/src/pages/X402Page.tsx +207 -0
  1082. package/agent/web/src/plugins/registry.ts +28 -11
  1083. package/agent/web/src/plugins/sdk.d.ts +160 -0
  1084. package/agent/web/src/themes/context.tsx +112 -5
  1085. package/agent/web/src/themes/fonts.ts +167 -0
  1086. package/agent/web/src/themes/index.ts +7 -0
  1087. package/agent/web/tsconfig.app.json +0 -1
  1088. package/agent/web/vite.config.ts +1 -8
  1089. package/agent/web/vitest.config.ts +16 -0
  1090. package/package.json +1 -1
  1091. package/agent/apps/desktop/package-lock.json +0 -18363
  1092. package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
  1093. package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
  1094. package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
  1095. package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
  1096. package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
  1097. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
  1098. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
  1099. package/agent/skills/diagramming/DESCRIPTION.md +0 -3
  1100. package/agent/skills/domain/DESCRIPTION.md +0 -24
  1101. package/agent/skills/gifs/DESCRIPTION.md +0 -3
  1102. package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
  1103. package/agent/skills/mcp/DESCRIPTION.md +0 -3
  1104. package/agent/skills/media/spotify/SKILL.md +0 -135
  1105. package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
  1106. package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
  1107. package/agent/skills/productivity/linear/SKILL.md +0 -380
  1108. package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
  1109. package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
  1110. package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
  1111. package/agent/ui-tui/package-lock.json +0 -7449
  1112. package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
  1113. package/agent/web/package-lock.json +0 -8887
  1114. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
  1115. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
  1116. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
  1117. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
  1118. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
  1119. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
  1120. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
  1121. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
  1122. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
  1123. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
  1124. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
  1125. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
  1126. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
  1127. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
  1128. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
  1129. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
  1130. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
  1131. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
  1132. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
  1133. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
  1134. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
  1135. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
  1136. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
  1137. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
  1138. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
  1139. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
  1140. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
  1141. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
  1142. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
  1143. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
  1144. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
  1145. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
  1146. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
  1147. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
  1148. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
  1149. /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
  1150. /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
  1151. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
  1152. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
  1153. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
  1154. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
  1155. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
  1156. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
  1157. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
  1158. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
  1159. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
  1160. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
  1161. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
  1162. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
  1163. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
  1164. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
  1165. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
  1166. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
  1167. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
  1168. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
  1169. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
  1170. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
  1171. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
  1172. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
  1173. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
  1174. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
  1175. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
  1176. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
  1177. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
  1178. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
  1179. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
  1180. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
  1181. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
  1182. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
  1183. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
  1184. /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
  1185. /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
  1186. /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
  1187. /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
  1188. /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
  1189. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
  1190. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
  1191. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
  1192. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
  1193. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
  1194. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
  1195. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
  1196. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
  1197. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
  1198. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
  1199. /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
  1200. /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
  1201. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
  1202. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
  1203. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
  1204. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
  1205. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
  1206. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
  1207. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
  1208. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
  1209. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
  1210. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
  1211. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
  1212. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/gates-taxonomy.md +0 -0
@@ -0,0 +1,1956 @@
1
+ """
2
+ WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform.
3
+
4
+ This adapter is a *complement* to ``whatsapp.py`` (the Baileys bridge), not
5
+ a replacement. The two are independent:
6
+
7
+ - ``whatsapp.py`` — unofficial Baileys bridge, personal accounts, no
8
+ public URL needed, account-ban risk.
9
+ - ``whatsapp_cloud.py`` (this file) — official Meta Cloud API, Business
10
+ account required, public webhook URL required,
11
+ token-based auth.
12
+
13
+ Both share gating / mention / formatting behavior via ``WhatsAppBehaviorMixin``.
14
+
15
+ Phase scope (this file evolves across phases):
16
+ - Phase 2 — outbound text via Graph API + webhook server with verify-token
17
+ handshake.
18
+ - Phase 3 — X-Hub-Signature-256 HMAC verification (raw body, constant-time)
19
+ + wamid replay protection + dispatch via handle_message. Phase 3
20
+ adapter is end-to-end usable for text DMs.
21
+ - Phase 4 — media upload + send (image/video/audio/document), inbound
22
+ media download via the Graph media endpoint, voice-note opus
23
+ conversion via ffmpeg with graceful MP3 fallback when ffmpeg
24
+ isn't on PATH. Document text injection for readable types.
25
+ - Phase 5 — 24-hour conversation window + template fallback.
26
+
27
+ Required env vars to enable the adapter:
28
+ - WHATSAPP_CLOUD_PHONE_NUMBER_ID (the Graph URL path component)
29
+ - WHATSAPP_CLOUD_ACCESS_TOKEN (System User permanent token)
30
+
31
+ Optional / Phase-3+:
32
+ - WHATSAPP_CLOUD_APP_ID
33
+ - WHATSAPP_CLOUD_APP_SECRET (HMAC key for X-Hub-Signature-256)
34
+ - WHATSAPP_CLOUD_WABA_ID (analytics / future use)
35
+ - WHATSAPP_CLOUD_VERIFY_TOKEN (hub.verify_token shared secret)
36
+ - WHATSAPP_CLOUD_WEBHOOK_HOST (default 0.0.0.0)
37
+ - WHATSAPP_CLOUD_WEBHOOK_PORT (default 8090)
38
+ - WHATSAPP_CLOUD_WEBHOOK_PATH (default /whatsapp/webhook)
39
+ - WHATSAPP_CLOUD_API_VERSION (default v20.0)
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ import hashlib
46
+ import hmac
47
+ import logging
48
+ import mimetypes
49
+ import os
50
+ import re
51
+ import shutil
52
+ import uuid
53
+ from collections import OrderedDict
54
+ from pathlib import Path
55
+ from typing import Any, Dict, Optional
56
+
57
+ try:
58
+ from aiohttp import web
59
+
60
+ AIOHTTP_AVAILABLE = True
61
+ except ImportError:
62
+ AIOHTTP_AVAILABLE = False
63
+ web = None # type: ignore[assignment]
64
+
65
+ try:
66
+ import httpx
67
+
68
+ HTTPX_AVAILABLE = True
69
+ except ImportError:
70
+ HTTPX_AVAILABLE = False
71
+ httpx = None # type: ignore[assignment]
72
+
73
+ from gateway.config import Platform, PlatformConfig
74
+ from gateway.platforms.base import (
75
+ BasePlatformAdapter,
76
+ MessageEvent,
77
+ MessageType,
78
+ SendResult,
79
+ SUPPORTED_DOCUMENT_TYPES,
80
+ )
81
+ from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
82
+ from hermes_constants import get_hermes_dir
83
+
84
+ logger = logging.getLogger(__name__)
85
+
86
+
87
+ DEFAULT_API_VERSION = "v20.0"
88
+ DEFAULT_WEBHOOK_HOST = "0.0.0.0"
89
+ DEFAULT_WEBHOOK_PORT = 8090
90
+ DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook"
91
+ GRAPH_API_BASE = "https://graph.facebook.com"
92
+ # Meta retries failed webhooks for up to 7 days. We don't need to remember
93
+ # every wamid for the full retry window — the practical risk is duplicate
94
+ # delivery within minutes, not days. 5000 entries with FIFO eviction is
95
+ # plenty for normal traffic and bounds memory.
96
+ WAMID_DEDUP_CACHE_SIZE = 5000
97
+ # Cap for the interactive-button state dicts and the per-chat last-wamid
98
+ # cache. Generous for any realistic number of in-flight prompts / chats.
99
+ INTERACTIVE_STATE_CACHE_SIZE = 1000
100
+
101
+ # Per-type size caps documented by Meta for the Cloud API /media endpoint.
102
+ # These are the hard limits; we refuse uploads above them with a clean
103
+ # error instead of round-tripping to Graph just to be rejected.
104
+ # https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media
105
+ _MEDIA_SIZE_LIMITS = {
106
+ "image": 5 * 1024 * 1024, # 5 MB (JPEG, PNG)
107
+ "video": 16 * 1024 * 1024, # 16 MB
108
+ "audio": 16 * 1024 * 1024, # 16 MB (MP3, AAC, AMR, OGG opus)
109
+ "document": 100 * 1024 * 1024, # 100 MB
110
+ "sticker": 100 * 1024, # 100 KB animated, 500 KB static
111
+ }
112
+
113
+ # Default mime types when we can't guess from the path's extension.
114
+ _DEFAULT_MIME = {
115
+ "image": "image/jpeg",
116
+ "video": "video/mp4",
117
+ "audio": "audio/mpeg",
118
+ "document": "application/octet-stream",
119
+ "sticker": "image/webp",
120
+ }
121
+
122
+ # ffmpeg location at import time. ``shutil.which`` honours PATHEXT on
123
+ # Windows so a user's ``ffmpeg.exe`` is picked up. None means MP3 voice
124
+ # falls back to "audio file attachment" rendering in WhatsApp.
125
+ _FFMPEG_PATH = shutil.which("ffmpeg")
126
+
127
+ # Python's mimetypes module returns RFC-correct but real-world-uncommon
128
+ # extensions for some types (audio/ogg → .oga since RFC 5334; audio/mp4
129
+ # → .mp4 instead of the de-facto .m4a for voice notes). Our downstream
130
+ # STT pipeline whitelists the common-in-the-wild extensions, so override
131
+ # the few Meta sends that don't match those defaults.
132
+ _WHATSAPP_MIME_EXTENSION_OVERRIDES: Dict[str, str] = {
133
+ # WhatsApp voice notes — opus codec inside an Ogg container.
134
+ "audio/ogg": ".ogg",
135
+ "audio/x-opus+ogg": ".ogg",
136
+ "audio/opus": ".ogg",
137
+ # iOS voice memos — AAC inside an MP4 container; STT tools expect .m4a.
138
+ "audio/mp4": ".m4a",
139
+ "audio/x-m4a": ".m4a",
140
+ # Image — mimetypes occasionally returns .jpe (legacy IANA) instead
141
+ # of .jpg, which trips up tools that switch on extension.
142
+ "image/jpeg": ".jpg",
143
+ }
144
+
145
+
146
+ def _ext_for_mime(mime: str) -> Optional[str]:
147
+ """Resolve a mime type to the file extension we want on disk.
148
+
149
+ Consults the override map first so types like ``audio/ogg`` produce
150
+ the extension downstream tools actually accept (``.ogg``, not the
151
+ technically-correct-but-broken ``.oga``). Falls back to Python's
152
+ ``mimetypes.guess_extension`` for anything we haven't pinned.
153
+ """
154
+ if not mime:
155
+ return None
156
+ primary = mime.split(";")[0].strip().lower()
157
+ override = _WHATSAPP_MIME_EXTENSION_OVERRIDES.get(primary)
158
+ if override:
159
+ return override
160
+ return mimetypes.guess_extension(primary) or None
161
+
162
+
163
+ # Inbound media cache lives under the user's hermes dir so it survives
164
+ # restarts and gateway reloads — same convention the Baileys bridge uses.
165
+ _INBOUND_MEDIA_CACHE = Path(get_hermes_dir("platforms/whatsapp_cloud/media", "whatsapp_cloud/media"))
166
+
167
+
168
+ def check_whatsapp_cloud_requirements() -> bool:
169
+ """Return whether transport dependencies are available.
170
+
171
+ aiohttp is needed for the webhook server (inbound). httpx is needed
172
+ for Graph API calls (outbound). Both ship with hermes-agent's default
173
+ dependency set, so this should always be True in normal installs.
174
+ """
175
+ return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
176
+
177
+
178
+ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
179
+ """WhatsApp Business Cloud API adapter.
180
+
181
+ Outbound: HTTPS POST to ``graph.facebook.com/<api_version>/<phone_id>/messages``.
182
+ Inbound: aiohttp server accepting Meta's webhook payloads.
183
+
184
+ The mixin must come first in the bases list so its ``format_message``
185
+ overrides ``BasePlatformAdapter.format_message`` (the base provides a
186
+ generic implementation that does not convert markdown to WhatsApp
187
+ syntax). The Baileys adapter does the same.
188
+ """
189
+
190
+ def __init__(self, config: PlatformConfig):
191
+ super().__init__(config, Platform.WHATSAPP_CLOUD)
192
+ extra = config.extra or {}
193
+
194
+ # Required
195
+ self._phone_number_id: str = str(extra.get("phone_number_id", "")).strip()
196
+ self._access_token: str = str(extra.get("access_token", "")).strip()
197
+
198
+ # Optional / used in later phases
199
+ self._app_id: str = str(extra.get("app_id", "")).strip()
200
+ self._app_secret: str = str(extra.get("app_secret", "")).strip()
201
+ self._waba_id: str = str(extra.get("waba_id", "")).strip()
202
+ self._verify_token: str = str(extra.get("verify_token", "")).strip()
203
+
204
+ # Webhook server config
205
+ self._webhook_host: str = str(extra.get("webhook_host", DEFAULT_WEBHOOK_HOST))
206
+ self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT))
207
+ self._webhook_path: str = self._normalize_path(
208
+ extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)
209
+ )
210
+ self._health_path: str = self._normalize_path(
211
+ extra.get("health_path", "/health")
212
+ )
213
+
214
+ # Graph API
215
+ self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION))
216
+
217
+ # Behavior-mixin contract: these names are read by the mixin's
218
+ # gating methods. WHATSAPP_CLOUD_* env vars take precedence so the
219
+ # two adapters can run in parallel with independent policies; the
220
+ # shared WHATSAPP_* names remain as fallback for single-adapter
221
+ # setups.
222
+ import os
223
+
224
+ self._reply_prefix: Optional[str] = extra.get("reply_prefix")
225
+ self._dm_policy: str = str(
226
+ extra.get("dm_policy")
227
+ or os.getenv("WHATSAPP_CLOUD_DM_POLICY")
228
+ or os.getenv("WHATSAPP_DM_POLICY", "open")
229
+ ).strip().lower()
230
+ self._allow_from: set[str] = self._normalize_allow_ids(
231
+ self._coerce_allow_list(
232
+ extra.get("allow_from")
233
+ or extra.get("allowFrom")
234
+ or os.getenv("WHATSAPP_CLOUD_ALLOW_FROM")
235
+ )
236
+ )
237
+ self._group_policy: str = str(
238
+ extra.get("group_policy")
239
+ or os.getenv("WHATSAPP_CLOUD_GROUP_POLICY")
240
+ or os.getenv("WHATSAPP_GROUP_POLICY", "open")
241
+ ).strip().lower()
242
+ self._group_allow_from: set[str] = self._normalize_allow_ids(
243
+ self._coerce_allow_list(
244
+ extra.get("group_allow_from")
245
+ or extra.get("groupAllowFrom")
246
+ or os.getenv("WHATSAPP_CLOUD_GROUP_ALLOW_FROM")
247
+ )
248
+ )
249
+ self._mention_patterns = self._compile_mention_patterns()
250
+
251
+ # Webhook dedup state — wamid → True. OrderedDict gives O(1) FIFO
252
+ # eviction. In-memory only; Phase 5 may promote to SessionDB if we
253
+ # decide we need replay protection across gateway restarts.
254
+ self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict()
255
+ self._duplicate_count: int = 0
256
+ self._accepted_count: int = 0
257
+ self._rejected_signature_count: int = 0
258
+
259
+ # One-shot flags for warnings that would otherwise spam the log.
260
+ self._warned_no_ffmpeg: bool = False
261
+
262
+ # Per-chat cache of the latest inbound wamid. Meta's typing
263
+ # indicator + read-receipt API requires a specific message_id
264
+ # to attach to (typically "the latest message in the
265
+ # conversation"). We refresh this on every accepted inbound
266
+ # message so ``send_typing`` always has a valid target without
267
+ # threading an extra kwarg through the gateway's base contract.
268
+ # In-memory only; on gateway restart the next inbound message
269
+ # repopulates it.
270
+ self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict()
271
+
272
+ # Interactive-button state. Each maps a short id (embedded in the
273
+ # outbound button payload) → the session/correlation key needed
274
+ # by the gateway's resolver. See ``_handle_interactive_reply`` for
275
+ # the dispatch table. Entries are popped when the user taps a
276
+ # button; ignored prompts would otherwise accumulate forever, so
277
+ # each dict is FIFO-capped via _bounded_put (oldest pending prompt
278
+ # evicted first — an evicted button tap degrades to the plain-text
279
+ # fallback path, same as after a gateway restart).
280
+ # _clarify_state: clarify_id → session_key (resolves via
281
+ # tools.clarify_gateway.resolve_gateway_clarify)
282
+ # _exec_approval_state: approval_id → session_key (resolves via
283
+ # tools.approval.resolve_gateway_approval)
284
+ # _slash_confirm_state: confirm_id → session_key (resolves via
285
+ # tools.slash_confirm.resolve)
286
+ self._clarify_state: "OrderedDict[str, str]" = OrderedDict()
287
+ self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict()
288
+ self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict()
289
+
290
+ # Runtime
291
+ self._runner = None
292
+ self._http_client: Optional["httpx.AsyncClient"] = None
293
+
294
+ # ------------------------------------------------------------------ helpers
295
+ @staticmethod
296
+ def _normalize_path(path: Any) -> str:
297
+ raw = str(path or "").strip() or "/"
298
+ return raw if raw.startswith("/") else f"/{raw}"
299
+
300
+ def _graph_url(self, path: str) -> str:
301
+ """Build a Graph API URL for this adapter's phone-number scope."""
302
+ if path.startswith("/"):
303
+ path = path[1:]
304
+ return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path}"
305
+
306
+ @staticmethod
307
+ def _bounded_put(cache: "OrderedDict[str, str]", key: str, value: str) -> None:
308
+ """Insert into a FIFO-capped OrderedDict, evicting oldest entries."""
309
+ cache[key] = value
310
+ while len(cache) > INTERACTIVE_STATE_CACHE_SIZE:
311
+ cache.popitem(last=False)
312
+
313
+ def _effective_reply_prefix(self) -> str:
314
+ """Cloud API has no self-chat concept — never prepend a reply prefix.
315
+
316
+ Override the mixin default which keys off WHATSAPP_MODE=self-chat
317
+ (a Baileys-only setting).
318
+ """
319
+ if self._reply_prefix is not None:
320
+ return self._reply_prefix.replace("\\n", "\n")
321
+ return ""
322
+
323
+ @staticmethod
324
+ def _normalize_allow_ids(ids: set[str]) -> set[str]:
325
+ """Normalize allowlist entries to bare wa_id form.
326
+
327
+ The Cloud API identifies users by bare wa_id (digits, no JID
328
+ suffix), while Baileys uses ``<digits>@s.whatsapp.net`` JIDs.
329
+ Users sharing an allowlist between both adapters (or pasting a
330
+ JID/phone number with ``+`` or separators) should still match,
331
+ so strip any ``@...`` suffix and non-digit characters.
332
+ """
333
+ normalized: set[str] = set()
334
+ for entry in ids:
335
+ bare = entry.split("@", 1)[0]
336
+ digits = re.sub(r"\D", "", bare)
337
+ normalized.add(digits or entry)
338
+ return normalized
339
+
340
+ def _is_dm_allowed(self, sender_id: str) -> bool:
341
+ """Allowlist check against the normalized bare wa_id."""
342
+ if self._dm_policy == "allowlist":
343
+ bare = re.sub(r"\D", "", str(sender_id).split("@", 1)[0])
344
+ return (bare or sender_id) in self._allow_from
345
+ return super()._is_dm_allowed(sender_id)
346
+
347
+ # ------------------------------------------------------------------ lifecycle
348
+ async def connect(self) -> bool:
349
+ if not check_whatsapp_cloud_requirements():
350
+ self._set_fatal_error(
351
+ "whatsapp_cloud_deps_missing",
352
+ "aiohttp and httpx are required for whatsapp_cloud — "
353
+ "reinstall hermes-agent.",
354
+ retryable=False,
355
+ )
356
+ return False
357
+ if not self._phone_number_id or not self._access_token:
358
+ self._set_fatal_error(
359
+ "whatsapp_cloud_unconfigured",
360
+ "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN "
361
+ "are required.",
362
+ retryable=False,
363
+ )
364
+ return False
365
+
366
+ # Outbound HTTP client. Tighter keepalive matches other platform
367
+ # adapters so idle CLOSE_WAIT drains promptly (#18451).
368
+ from gateway.platforms._http_client_limits import platform_httpx_limits
369
+
370
+ self._http_client = httpx.AsyncClient(
371
+ timeout=30.0, limits=platform_httpx_limits()
372
+ )
373
+
374
+ # Inbound webhook server.
375
+ app = web.Application()
376
+ app.router.add_get(self._health_path, self._handle_health)
377
+ app.router.add_get(self._webhook_path, self._handle_verify)
378
+ app.router.add_post(self._webhook_path, self._handle_webhook)
379
+
380
+ self._runner = web.AppRunner(app)
381
+ await self._runner.setup()
382
+ site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port)
383
+ await site.start()
384
+
385
+ self._mark_connected()
386
+ logger.info(
387
+ "[whatsapp_cloud] Listening on %s:%d%s (Graph %s, phone_id=%s)",
388
+ self._webhook_host,
389
+ self._webhook_port,
390
+ self._webhook_path,
391
+ self._api_version,
392
+ self._phone_number_id,
393
+ )
394
+ if not self._verify_token:
395
+ logger.warning(
396
+ "[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — "
397
+ "the GET subscription handshake will fail until it is."
398
+ )
399
+ if not self._app_secret:
400
+ logger.warning(
401
+ "[whatsapp_cloud] WHATSAPP_CLOUD_APP_SECRET is not set — "
402
+ "incoming webhook POSTs will be refused with 503. Set "
403
+ "the app secret to enable inbound message delivery."
404
+ )
405
+ return True
406
+
407
+ async def disconnect(self) -> None:
408
+ if self._runner is not None:
409
+ try:
410
+ await self._runner.cleanup()
411
+ except Exception:
412
+ logger.exception("[whatsapp_cloud] webhook server cleanup failed")
413
+ self._runner = None
414
+ if self._http_client is not None:
415
+ try:
416
+ await self._http_client.aclose()
417
+ except Exception:
418
+ logger.exception("[whatsapp_cloud] http client close failed")
419
+ self._http_client = None
420
+ self._mark_disconnected()
421
+
422
+ # ------------------------------------------------------------------ outbound
423
+ async def send(
424
+ self,
425
+ chat_id: str,
426
+ content: str,
427
+ reply_to: Optional[str] = None,
428
+ metadata: Optional[Dict[str, Any]] = None,
429
+ ) -> SendResult:
430
+ """Send a text message via Graph API.
431
+
432
+ ``chat_id`` is the recipient's WhatsApp ID (``wa_id``) — typically
433
+ their phone number with country code, no plus sign.
434
+ """
435
+ if self._http_client is None:
436
+ return SendResult(success=False, error="Not connected")
437
+ if not content or not content.strip():
438
+ return SendResult(success=True, message_id=None)
439
+
440
+ formatted = self.format_message(content)
441
+ chunks = self.truncate_message(formatted, self._outgoing_chunk_limit())
442
+
443
+ url = self._graph_url("messages")
444
+ headers = {
445
+ "Authorization": f"Bearer {self._access_token}",
446
+ "Content-Type": "application/json",
447
+ }
448
+
449
+ last_message_id: Optional[str] = None
450
+ for idx, chunk in enumerate(chunks):
451
+ payload: Dict[str, Any] = {
452
+ "messaging_product": "whatsapp",
453
+ "recipient_type": "individual",
454
+ "to": chat_id,
455
+ "type": "text",
456
+ "text": {"body": chunk, "preview_url": True},
457
+ }
458
+ if reply_to and idx == 0:
459
+ # Quote the user's message on the first chunk only.
460
+ payload["context"] = {"message_id": reply_to}
461
+ try:
462
+ resp = await self._http_client.post(url, headers=headers, json=payload)
463
+ except Exception as exc:
464
+ logger.exception("[whatsapp_cloud] send failed")
465
+ return SendResult(success=False, error=str(exc))
466
+
467
+ if resp.status_code != 200:
468
+ # Meta returns structured errors in the body — surface them
469
+ # to the caller so log lines have actionable context.
470
+ try:
471
+ body = resp.json()
472
+ except Exception:
473
+ body = {"raw": resp.text[:500]}
474
+ error_msg = self._format_graph_error(body, resp.status_code)
475
+ logger.warning(
476
+ "[whatsapp_cloud] send rejected (status=%d): %s",
477
+ resp.status_code,
478
+ error_msg,
479
+ )
480
+ return SendResult(success=False, error=error_msg)
481
+
482
+ try:
483
+ data = resp.json()
484
+ ids = data.get("messages") or []
485
+ if ids:
486
+ last_message_id = ids[0].get("id")
487
+ except Exception:
488
+ pass
489
+
490
+ return SendResult(success=True, message_id=last_message_id)
491
+
492
+ # ------------------------------------------------------------------ typing indicator + read receipts
493
+ #
494
+ # Meta couples these into a single API call: a POST to /messages
495
+ # with ``status: "read"`` marks the message read (blue double
496
+ # checkmarks), and the optional ``typing_indicator`` field
497
+ # additionally shows the user a "typing..." pip in their chat UI.
498
+ # The indicator auto-dismisses when we respond OR after 25 seconds,
499
+ # whichever comes first — so this matches "I see your message and
500
+ # I'm working on a reply" UX exactly.
501
+ #
502
+ # The API requires a specific message_id to attach to. We cache the
503
+ # latest inbound wamid per chat in _last_inbound_wamid_by_chat
504
+ # (refreshed in _build_message_event_from_cloud) so this method can
505
+ # look it up without needing the gateway base contract to plumb
506
+ # event.message_id into send_typing's signature.
507
+
508
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
509
+ """Mark the latest inbound message as read AND show a typing
510
+ indicator in the user's chat UI.
511
+
512
+ Best-effort: any error (no inbound wamid yet, network failure,
513
+ stale token, message older than 30 days) is swallowed silently
514
+ so the agent's main reply path isn't blocked by UX polish.
515
+ """
516
+ if self._http_client is None:
517
+ return
518
+ wamid = self._last_inbound_wamid_by_chat.get(chat_id)
519
+ if not wamid:
520
+ # No inbound message yet for this chat (or cache cleared on
521
+ # restart) — skip. The next inbound message will repopulate.
522
+ return
523
+
524
+ url = self._graph_url("messages")
525
+ headers = {
526
+ "Authorization": f"Bearer {self._access_token}",
527
+ "Content-Type": "application/json",
528
+ }
529
+ payload = {
530
+ "messaging_product": "whatsapp",
531
+ "status": "read",
532
+ "message_id": wamid,
533
+ "typing_indicator": {"type": "text"},
534
+ }
535
+ try:
536
+ resp = await self._http_client.post(url, headers=headers, json=payload)
537
+ except Exception:
538
+ # Network / connection error — silent fail. Typing UX must
539
+ # never block message dispatch.
540
+ return
541
+ # Best-effort: surface 4xx for ops visibility but don't raise.
542
+ # Code 131009 = "Parameter value is not valid" (typically wamid
543
+ # > 30 days old) — common after a long-quiet conversation, log
544
+ # at info not warning.
545
+ if resp.status_code != 200:
546
+ try:
547
+ body = resp.json()
548
+ code = ((body or {}).get("error") or {}).get("code")
549
+ except Exception:
550
+ code = None
551
+ if code == 131009:
552
+ logger.info(
553
+ "[whatsapp_cloud] typing/read indicator rejected: "
554
+ "wamid %s likely older than 30 days", wamid,
555
+ )
556
+ else:
557
+ logger.debug(
558
+ "[whatsapp_cloud] typing/read indicator returned %d (%s)",
559
+ resp.status_code, code,
560
+ )
561
+
562
+ # ------------------------------------------------------------------ interactive messages
563
+ #
564
+ # WhatsApp Cloud supports two interactive primitives we use here:
565
+ # * ``interactive.type=button`` — up to 3 quick-reply buttons. Each
566
+ # button has an ``id`` (≤256 chars, returned verbatim on tap) and
567
+ # a ``title`` (≤20 chars, the label shown). Used for clarify with
568
+ # ≤3 choices, exec_approval, and slash_confirm.
569
+ # * ``interactive.type=list`` — a single "Tap to choose" button
570
+ # that opens a sheet with up to 10 rows. Used for clarify with
571
+ # >3 choices and the model picker.
572
+ #
573
+ # Unlike utility templates these are FREE-FORM and need no Meta-side
574
+ # approval. They only work *inside* the 24-hour conversation window —
575
+ # which is fine because all five senders below fire in direct response
576
+ # to a user message (clarify mid-conversation, approval mid-tool-call,
577
+ # etc.) so we're always inside the window when they're invoked.
578
+
579
+ async def _post_interactive(
580
+ self,
581
+ chat_id: str,
582
+ interactive_body: Dict[str, Any],
583
+ reply_to: Optional[str] = None,
584
+ ) -> SendResult:
585
+ """Low-level POST for an ``interactive`` message payload.
586
+
587
+ ``interactive_body`` is the inner ``interactive: {...}`` dict —
588
+ the caller supplies ``type``, ``body``, and ``action``. This
589
+ wrapper handles auth, error mapping, and message_id extraction so
590
+ each send_* method stays focused on its own button shape.
591
+ """
592
+ if self._http_client is None:
593
+ return SendResult(success=False, error="Not connected")
594
+
595
+ url = self._graph_url("messages")
596
+ headers = {
597
+ "Authorization": f"Bearer {self._access_token}",
598
+ "Content-Type": "application/json",
599
+ }
600
+ payload: Dict[str, Any] = {
601
+ "messaging_product": "whatsapp",
602
+ "recipient_type": "individual",
603
+ "to": chat_id,
604
+ "type": "interactive",
605
+ "interactive": interactive_body,
606
+ }
607
+ if reply_to:
608
+ payload["context"] = {"message_id": reply_to}
609
+
610
+ try:
611
+ resp = await self._http_client.post(url, headers=headers, json=payload)
612
+ except Exception as exc:
613
+ logger.exception("[whatsapp_cloud] interactive send failed")
614
+ return SendResult(success=False, error=str(exc))
615
+
616
+ if resp.status_code != 200:
617
+ try:
618
+ body = resp.json()
619
+ except Exception:
620
+ body = {"raw": resp.text[:500]}
621
+ error_msg = self._format_graph_error(body, resp.status_code)
622
+ logger.warning(
623
+ "[whatsapp_cloud] interactive rejected (status=%d): %s",
624
+ resp.status_code, error_msg,
625
+ )
626
+ return SendResult(success=False, error=error_msg)
627
+
628
+ last_message_id: Optional[str] = None
629
+ try:
630
+ data = resp.json()
631
+ ids = data.get("messages") or []
632
+ if ids:
633
+ last_message_id = ids[0].get("id")
634
+ except Exception:
635
+ pass
636
+ return SendResult(success=True, message_id=last_message_id)
637
+
638
+ @staticmethod
639
+ def _truncate_button_label(text: str, limit: int = 20) -> str:
640
+ """WhatsApp caps quick-reply button titles at 20 chars and list-row
641
+ titles at 24. Truncate with an ellipsis so we surface as much of
642
+ the choice as fits."""
643
+ text = str(text or "").strip()
644
+ if len(text) <= limit:
645
+ return text
646
+ # Reserve 1 char for the ellipsis. WhatsApp counts the ellipsis
647
+ # toward the limit.
648
+ return text[: max(1, limit - 1)] + "…"
649
+
650
+ @staticmethod
651
+ def _truncate_body(text: str, limit: int = 1024) -> str:
652
+ """``interactive.body.text`` caps at 1024 chars."""
653
+ text = str(text or "")
654
+ if len(text) <= limit:
655
+ return text
656
+ return text[: limit - 3] + "..."
657
+
658
+ async def send_clarify(
659
+ self,
660
+ chat_id: str,
661
+ question: str,
662
+ choices: Optional[list],
663
+ clarify_id: str,
664
+ session_key: str,
665
+ metadata: Optional[Dict[str, Any]] = None,
666
+ ) -> SendResult:
667
+ """Render a clarify prompt as native WhatsApp interactive buttons.
668
+
669
+ - 1–3 choices → ``interactive.type=button`` (inline pill buttons).
670
+ - 4+ choices → ``interactive.type=list`` (tap-to-open sheet with
671
+ up to 10 rows). Telegram's "Other (type answer)" escape hatch
672
+ is appended as the final row, picking it flips the entry into
673
+ text-capture mode handled by the gateway's text intercept.
674
+ - 0 choices (open-ended) → plain text question; the next message
675
+ in the session is captured by the gateway and resolves clarify.
676
+
677
+ The button ``id`` field carries ``cl:<clarify_id>:<idx>`` (or
678
+ ``:other``); inbound webhook parsing dispatches on the prefix.
679
+ """
680
+ if self._http_client is None:
681
+ return SendResult(success=False, error="Not connected")
682
+
683
+ question = (question or "").strip()
684
+ reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
685
+
686
+ # Open-ended → just send the question, gateway captures next msg.
687
+ if not choices:
688
+ return await self.send(chat_id, f"❓ {question}", reply_to=reply_to)
689
+
690
+ # Mirror Telegram: render full choice text in body so long
691
+ # options aren't truncated to the 20-char button label cap.
692
+ # Truncate choices to MAX_CHOICES (4) — the tool layer enforces
693
+ # this already, but be defensive.
694
+ choices_list = [str(c).strip() for c in choices[:10] if str(c).strip()]
695
+ option_lines = "\n".join(
696
+ f"{i + 1}. {c}" for i, c in enumerate(choices_list)
697
+ )
698
+ body_text = self._truncate_body(f"❓ {question}\n\n{option_lines}")
699
+
700
+ if len(choices_list) <= 3:
701
+ buttons = [
702
+ {
703
+ "type": "reply",
704
+ "reply": {
705
+ "id": f"cl:{clarify_id}:{idx}",
706
+ "title": self._truncate_button_label(str(idx + 1)),
707
+ },
708
+ }
709
+ for idx in range(len(choices_list))
710
+ ]
711
+ interactive: Dict[str, Any] = {
712
+ "type": "button",
713
+ "body": {"text": body_text},
714
+ "action": {"buttons": buttons},
715
+ }
716
+ else:
717
+ # List mode: rows must each have id + title (≤24 chars).
718
+ # Description (≤72 chars) renders below the title — we put
719
+ # the truncated choice text there for skimmability.
720
+ rows = []
721
+ for idx, choice_text in enumerate(choices_list):
722
+ rows.append({
723
+ "id": f"cl:{clarify_id}:{idx}",
724
+ "title": self._truncate_button_label(f"{idx + 1}", limit=24),
725
+ "description": self._truncate_button_label(choice_text, limit=72),
726
+ })
727
+ rows.append({
728
+ "id": f"cl:{clarify_id}:other",
729
+ "title": "✏️ Other",
730
+ "description": "Type your own answer",
731
+ })
732
+ interactive = {
733
+ "type": "list",
734
+ "body": {"text": body_text},
735
+ "action": {
736
+ "button": "Choose",
737
+ "sections": [{"title": "Options", "rows": rows}],
738
+ },
739
+ }
740
+
741
+ result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
742
+ if result.success:
743
+ self._bounded_put(self._clarify_state, clarify_id, session_key)
744
+ return result
745
+
746
+ async def send_exec_approval(
747
+ self,
748
+ chat_id: str,
749
+ command: str,
750
+ session_key: str,
751
+ description: str = "dangerous command",
752
+ metadata: Optional[Dict[str, Any]] = None,
753
+ ) -> SendResult:
754
+ """Render a dangerous-command approval prompt with native buttons.
755
+
756
+ Two quick-reply buttons (Approve / Deny). Tapping resolves the
757
+ waiting agent via ``tools.approval.resolve_gateway_approval`` —
758
+ same mechanism as the text ``/approve`` flow. The agent thread
759
+ is blocked until the user taps or types a response.
760
+ """
761
+ if self._http_client is None:
762
+ return SendResult(success=False, error="Not connected")
763
+
764
+ # WhatsApp body caps at 1024 chars; reserve room for the
765
+ # framing prose around the command.
766
+ cmd = command or ""
767
+ cmd_preview = cmd if len(cmd) <= 800 else cmd[:800] + "..."
768
+ body_text = self._truncate_body(
769
+ f"⚠️ *Command Approval Required*\n\n"
770
+ f"```\n{cmd_preview}\n```\n\n"
771
+ f"Reason: {description}"
772
+ )
773
+
774
+ approval_id = uuid.uuid4().hex[:12]
775
+ reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
776
+
777
+ interactive = {
778
+ "type": "button",
779
+ "body": {"text": body_text},
780
+ "action": {
781
+ "buttons": [
782
+ {
783
+ "type": "reply",
784
+ "reply": {"id": f"appr:{approval_id}:approve", "title": "✅ Approve"},
785
+ },
786
+ {
787
+ "type": "reply",
788
+ "reply": {"id": f"appr:{approval_id}:deny", "title": "❌ Deny"},
789
+ },
790
+ ],
791
+ },
792
+ }
793
+
794
+ result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
795
+ if result.success:
796
+ self._bounded_put(self._exec_approval_state, approval_id, session_key)
797
+ return result
798
+
799
+ async def send_slash_confirm(
800
+ self,
801
+ chat_id: str,
802
+ title: str,
803
+ message: str,
804
+ session_key: str,
805
+ confirm_id: str,
806
+ metadata: Optional[Dict[str, Any]] = None,
807
+ ) -> SendResult:
808
+ """Render a 3-button slash-command confirmation prompt.
809
+
810
+ Mirrors Telegram's send_slash_confirm: Approve Once / Always /
811
+ Cancel. The confirm_id is supplied by the caller (slash command
812
+ handler) — we just store the session_key mapping for the inbound
813
+ resolver to look up.
814
+ """
815
+ if self._http_client is None:
816
+ return SendResult(success=False, error="Not connected")
817
+
818
+ body_text = self._truncate_body(f"*{title}*\n\n{message}")
819
+ reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
820
+
821
+ interactive = {
822
+ "type": "button",
823
+ "body": {"text": body_text},
824
+ "action": {
825
+ "buttons": [
826
+ {
827
+ "type": "reply",
828
+ "reply": {"id": f"sc:once:{confirm_id}", "title": "✅ Approve Once"},
829
+ },
830
+ {
831
+ "type": "reply",
832
+ "reply": {"id": f"sc:always:{confirm_id}", "title": "🔒 Always"},
833
+ },
834
+ {
835
+ "type": "reply",
836
+ "reply": {"id": f"sc:cancel:{confirm_id}", "title": "❌ Cancel"},
837
+ },
838
+ ],
839
+ },
840
+ }
841
+
842
+ result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
843
+ if result.success:
844
+ self._bounded_put(self._slash_confirm_state, confirm_id, session_key)
845
+ return result
846
+
847
+ @staticmethod
848
+ def _format_graph_error(body: Dict[str, Any], status_code: int) -> str:
849
+ err = (body or {}).get("error") or {}
850
+ # Graph API error shape:
851
+ # {"error": {"message": "...", "type": "...", "code": ..., "fbtrace_id": "..."}}
852
+ message = err.get("message") or body.get("raw") or "unknown error"
853
+ code = err.get("code")
854
+ if code is not None:
855
+ return f"graph error {code} (HTTP {status_code}): {message}"
856
+ return f"HTTP {status_code}: {message}"
857
+
858
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
859
+ # Cloud API doesn't expose a direct "chat info" endpoint the way
860
+ # Slack/Discord do — we just echo the wa_id. Profile name (when
861
+ # known) flows in via webhook ``contacts[].profile.name`` and is
862
+ # cached on the MessageEvent, not here.
863
+ return {"name": chat_id, "type": "dm"}
864
+
865
+ # ------------------------------------------------------------------ outbound media
866
+ async def _upload_media(
867
+ self,
868
+ file_path: str,
869
+ media_kind: str,
870
+ mime_type: Optional[str] = None,
871
+ ) -> tuple[Optional[str], Optional[str]]:
872
+ """Upload a local file to the Graph /media endpoint.
873
+
874
+ Returns ``(media_id, None)`` on success, ``(None, error_string)``
875
+ on failure. Two-step send: this gets the id, then ``_send_media``
876
+ references it. Used when we have a local file and no public URL.
877
+
878
+ ``media_kind`` is one of "image", "video", "audio", "document",
879
+ "sticker" — selects size cap + default mime fallback.
880
+ """
881
+ if self._http_client is None:
882
+ return None, "Not connected"
883
+ if not os.path.exists(file_path):
884
+ return None, f"File not found: {file_path}"
885
+
886
+ size = os.path.getsize(file_path)
887
+ cap = _MEDIA_SIZE_LIMITS.get(media_kind, _MEDIA_SIZE_LIMITS["document"])
888
+ if size > cap:
889
+ return None, (
890
+ f"File {os.path.basename(file_path)} is {size} bytes; "
891
+ f"Cloud API {media_kind} cap is {cap} bytes"
892
+ )
893
+
894
+ if not mime_type:
895
+ mime_type, _ = mimetypes.guess_type(file_path)
896
+ if not mime_type:
897
+ mime_type = _DEFAULT_MIME.get(media_kind, "application/octet-stream")
898
+
899
+ url = self._graph_url("media")
900
+ headers = {"Authorization": f"Bearer {self._access_token}"}
901
+ try:
902
+ with open(file_path, "rb") as fh:
903
+ files = {
904
+ "file": (os.path.basename(file_path), fh, mime_type),
905
+ "messaging_product": (None, "whatsapp"),
906
+ "type": (None, mime_type),
907
+ }
908
+ resp = await self._http_client.post(url, headers=headers, files=files)
909
+ except Exception as exc:
910
+ logger.exception("[whatsapp_cloud] media upload failed")
911
+ return None, str(exc)
912
+
913
+ if resp.status_code != 200:
914
+ try:
915
+ body = resp.json()
916
+ except Exception:
917
+ body = {"raw": resp.text[:500]}
918
+ return None, self._format_graph_error(body, resp.status_code)
919
+
920
+ try:
921
+ data = resp.json()
922
+ media_id = data.get("id")
923
+ except Exception:
924
+ media_id = None
925
+ if not media_id:
926
+ return None, "Upload response missing 'id'"
927
+ return media_id, None
928
+
929
+ async def _send_media(
930
+ self,
931
+ chat_id: str,
932
+ media_kind: str,
933
+ *,
934
+ media_id: Optional[str] = None,
935
+ media_link: Optional[str] = None,
936
+ caption: Optional[str] = None,
937
+ filename: Optional[str] = None,
938
+ reply_to: Optional[str] = None,
939
+ ) -> SendResult:
940
+ """POST a media message referencing either an uploaded media_id or
941
+ a public ``link``.
942
+
943
+ Exactly one of ``media_id`` or ``media_link`` must be set. Captions
944
+ and filenames are passed through where Meta accepts them (caption
945
+ on image/video/document; filename on document only).
946
+ """
947
+ if self._http_client is None:
948
+ return SendResult(success=False, error="Not connected")
949
+ if bool(media_id) == bool(media_link):
950
+ return SendResult(
951
+ success=False,
952
+ error="Exactly one of media_id or media_link must be set",
953
+ )
954
+
955
+ url = self._graph_url("messages")
956
+ headers = {
957
+ "Authorization": f"Bearer {self._access_token}",
958
+ "Content-Type": "application/json",
959
+ }
960
+
961
+ media_block: Dict[str, Any] = {}
962
+ if media_id:
963
+ media_block["id"] = media_id
964
+ else:
965
+ media_block["link"] = media_link
966
+ if caption and media_kind in {"image", "video", "document"}:
967
+ media_block["caption"] = caption
968
+ if filename and media_kind == "document":
969
+ media_block["filename"] = filename
970
+
971
+ payload: Dict[str, Any] = {
972
+ "messaging_product": "whatsapp",
973
+ "recipient_type": "individual",
974
+ "to": chat_id,
975
+ "type": media_kind,
976
+ media_kind: media_block,
977
+ }
978
+ if reply_to:
979
+ payload["context"] = {"message_id": reply_to}
980
+
981
+ try:
982
+ resp = await self._http_client.post(url, headers=headers, json=payload)
983
+ except Exception as exc:
984
+ logger.exception("[whatsapp_cloud] media send failed")
985
+ return SendResult(success=False, error=str(exc))
986
+
987
+ if resp.status_code != 200:
988
+ try:
989
+ body = resp.json()
990
+ except Exception:
991
+ body = {"raw": resp.text[:500]}
992
+ error_msg = self._format_graph_error(body, resp.status_code)
993
+ logger.warning(
994
+ "[whatsapp_cloud] media send rejected (status=%d, kind=%s): %s",
995
+ resp.status_code, media_kind, error_msg,
996
+ )
997
+ return SendResult(success=False, error=error_msg)
998
+
999
+ try:
1000
+ data = resp.json()
1001
+ ids = data.get("messages") or []
1002
+ wamid = ids[0].get("id") if ids else None
1003
+ except Exception:
1004
+ wamid = None
1005
+ return SendResult(success=True, message_id=wamid)
1006
+
1007
+ async def _send_media_from_path_or_link(
1008
+ self,
1009
+ chat_id: str,
1010
+ source: str,
1011
+ media_kind: str,
1012
+ *,
1013
+ caption: Optional[str] = None,
1014
+ filename: Optional[str] = None,
1015
+ reply_to: Optional[str] = None,
1016
+ mime_type: Optional[str] = None,
1017
+ ) -> SendResult:
1018
+ """Smart dispatcher: HTTPS URL → ``link`` send; local path → upload + ``id`` send.
1019
+
1020
+ Prefers the ``link`` path when possible (one fewer Graph round
1021
+ trip). Meta fetches from the URL themselves. Used as the common
1022
+ backend for ``send_image`` / ``send_video`` / etc. — keeps the
1023
+ public method bodies thin.
1024
+ """
1025
+ if source.startswith(("http://", "https://")):
1026
+ return await self._send_media(
1027
+ chat_id,
1028
+ media_kind,
1029
+ media_link=source,
1030
+ caption=caption,
1031
+ filename=filename,
1032
+ reply_to=reply_to,
1033
+ )
1034
+ media_id, err = await self._upload_media(source, media_kind, mime_type)
1035
+ if err:
1036
+ return SendResult(success=False, error=err)
1037
+ return await self._send_media(
1038
+ chat_id,
1039
+ media_kind,
1040
+ media_id=media_id,
1041
+ caption=caption,
1042
+ filename=filename,
1043
+ reply_to=reply_to,
1044
+ )
1045
+
1046
+ async def send_image(
1047
+ self,
1048
+ chat_id: str,
1049
+ image_url: str,
1050
+ caption: Optional[str] = None,
1051
+ reply_to: Optional[str] = None,
1052
+ **kwargs,
1053
+ ) -> SendResult:
1054
+ """Send an image by public URL. Prefers Meta's ``link`` mode.
1055
+
1056
+ ``**kwargs`` absorbs platform-agnostic args the base class passes
1057
+ (e.g. ``metadata``) that the Cloud API doesn't have a use for.
1058
+ Mirrors send_image_file / send_video / send_voice / send_document.
1059
+ """
1060
+ return await self._send_media_from_path_or_link(
1061
+ chat_id, image_url, "image", caption=caption, reply_to=reply_to
1062
+ )
1063
+
1064
+ async def send_image_file(
1065
+ self,
1066
+ chat_id: str,
1067
+ image_path: str,
1068
+ caption: Optional[str] = None,
1069
+ reply_to: Optional[str] = None,
1070
+ **kwargs,
1071
+ ) -> SendResult:
1072
+ """Send a local image file via two-step upload + id."""
1073
+ return await self._send_media_from_path_or_link(
1074
+ chat_id, image_path, "image", caption=caption, reply_to=reply_to
1075
+ )
1076
+
1077
+ async def send_video(
1078
+ self,
1079
+ chat_id: str,
1080
+ video_path: str,
1081
+ caption: Optional[str] = None,
1082
+ reply_to: Optional[str] = None,
1083
+ **kwargs,
1084
+ ) -> SendResult:
1085
+ """Send a video. Local path → upload; HTTPS URL → link mode."""
1086
+ return await self._send_media_from_path_or_link(
1087
+ chat_id, video_path, "video", caption=caption, reply_to=reply_to
1088
+ )
1089
+
1090
+ async def send_voice(
1091
+ self,
1092
+ chat_id: str,
1093
+ audio_path: str,
1094
+ caption: Optional[str] = None,
1095
+ reply_to: Optional[str] = None,
1096
+ **kwargs,
1097
+ ) -> SendResult:
1098
+ """Send an audio file as a WhatsApp voice message.
1099
+
1100
+ WhatsApp renders ``audio/ogg; codecs=opus`` as the green
1101
+ voice-note bubble; other audio types (MP3, AAC, etc.) appear as
1102
+ a generic audio attachment. Hermes TTS produces MP3, so we try
1103
+ ffmpeg conversion to opus first and fall back to sending the
1104
+ MP3 as-is when ffmpeg is unavailable.
1105
+ """
1106
+ source = audio_path
1107
+ mime_type: Optional[str] = None
1108
+
1109
+ is_local_mp3 = (
1110
+ not audio_path.startswith(("http://", "https://"))
1111
+ and audio_path.lower().endswith(".mp3")
1112
+ and os.path.exists(audio_path)
1113
+ )
1114
+ if is_local_mp3:
1115
+ opus_path = await self._convert_to_opus(audio_path)
1116
+ if opus_path:
1117
+ try:
1118
+ result = await self._send_media_from_path_or_link(
1119
+ chat_id, opus_path, "audio",
1120
+ caption=caption, reply_to=reply_to,
1121
+ mime_type="audio/ogg; codecs=opus",
1122
+ )
1123
+ finally:
1124
+ # The .ogg is a transient conversion artifact next to
1125
+ # the source MP3 — clean it up after upload so voice
1126
+ # sends don't leak a file per message.
1127
+ try:
1128
+ os.unlink(opus_path)
1129
+ except OSError:
1130
+ pass
1131
+ return result
1132
+ # Will deliver as MP3 attachment, not voice bubble.
1133
+ # Warn-once is logged inside _convert_to_opus.
1134
+ mime_type = "audio/mpeg"
1135
+
1136
+ return await self._send_media_from_path_or_link(
1137
+ chat_id, source, "audio",
1138
+ caption=caption, reply_to=reply_to, mime_type=mime_type,
1139
+ )
1140
+
1141
+ async def send_document(
1142
+ self,
1143
+ chat_id: str,
1144
+ file_path: str,
1145
+ caption: Optional[str] = None,
1146
+ file_name: Optional[str] = None,
1147
+ reply_to: Optional[str] = None,
1148
+ **kwargs,
1149
+ ) -> SendResult:
1150
+ """Send a document attachment with optional filename + caption."""
1151
+ return await self._send_media_from_path_or_link(
1152
+ chat_id, file_path, "document",
1153
+ caption=caption,
1154
+ filename=file_name or os.path.basename(file_path),
1155
+ reply_to=reply_to,
1156
+ )
1157
+
1158
+ # ------------------------------------------------------------------ opus conversion
1159
+ async def _convert_to_opus(self, mp3_path: str) -> Optional[str]:
1160
+ """Convert an MP3 to ``audio/ogg; codecs=opus`` for voice bubbles.
1161
+
1162
+ Returns the path to the converted file, or None if ffmpeg is
1163
+ missing / conversion fails (caller falls back to sending the
1164
+ original MP3 as an audio file).
1165
+
1166
+ ``-application voip`` tunes the opus encoder for speech.
1167
+ ``-b:a 32k -vbr on`` matches the bitrate WhatsApp produces for
1168
+ native voice notes (small files, good intelligibility).
1169
+ """
1170
+ if not _FFMPEG_PATH:
1171
+ self._warn_once_no_ffmpeg()
1172
+ return None
1173
+
1174
+ out_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
1175
+ try:
1176
+ proc = await asyncio.create_subprocess_exec(
1177
+ _FFMPEG_PATH, "-y", "-i", mp3_path,
1178
+ "-c:a", "libopus", "-b:a", "32k", "-vbr", "on",
1179
+ "-application", "voip", out_path,
1180
+ stdout=asyncio.subprocess.DEVNULL,
1181
+ stderr=asyncio.subprocess.PIPE,
1182
+ )
1183
+ _, stderr = await proc.communicate()
1184
+ if proc.returncode != 0 or not Path(out_path).exists():
1185
+ logger.error(
1186
+ "[whatsapp_cloud] ffmpeg opus conversion failed "
1187
+ "(returncode=%s): %s",
1188
+ proc.returncode,
1189
+ (stderr or b"").decode("utf-8", errors="replace")[:500],
1190
+ )
1191
+ return None
1192
+ return out_path
1193
+ except Exception:
1194
+ logger.exception("[whatsapp_cloud] ffmpeg subprocess raised")
1195
+ return None
1196
+
1197
+ def _warn_once_no_ffmpeg(self) -> None:
1198
+ if self._warned_no_ffmpeg:
1199
+ return
1200
+ self._warned_no_ffmpeg = True
1201
+ logger.warning(
1202
+ "[whatsapp_cloud] ffmpeg not found on PATH — voice messages will "
1203
+ "be delivered as MP3 audio attachments instead of native voice "
1204
+ "notes (green waveform bubble). Install ffmpeg to enable: "
1205
+ "Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, "
1206
+ "Linux package manager."
1207
+ )
1208
+
1209
+ # ------------------------------------------------------------------ inbound media
1210
+ async def _download_media_to_cache(
1211
+ self,
1212
+ media_id: str,
1213
+ *,
1214
+ ext_hint: Optional[str] = None,
1215
+ ) -> tuple[Optional[str], Optional[str]]:
1216
+ """Two-step Graph media download: ``GET /<id>`` → temp URL → bytes.
1217
+
1218
+ Returns ``(local_path, mime_type)`` on success. ``mime_type``
1219
+ falls back to what Graph reports in the metadata response.
1220
+ Returns ``(None, None)`` on any failure (logged).
1221
+
1222
+ The temporary URL from step 1 is signed and expires in ~5
1223
+ minutes; we download immediately and never persist the URL.
1224
+ """
1225
+ if self._http_client is None:
1226
+ return None, None
1227
+ # Defense in depth: media_id comes from the (signature-verified)
1228
+ # webhook payload, but it's interpolated into both a Graph URL and
1229
+ # a cache filename below — refuse anything that isn't a plain
1230
+ # Meta-style media id so a hostile payload can't traverse paths.
1231
+ media_id = str(media_id).strip()
1232
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", media_id):
1233
+ logger.warning(
1234
+ "[whatsapp_cloud] refusing malformed media id %r", media_id[:64]
1235
+ )
1236
+ return None, None
1237
+ headers = {"Authorization": f"Bearer {self._access_token}"}
1238
+
1239
+ # Step 1 — metadata (gives us a temporary signed URL + mime)
1240
+ try:
1241
+ meta_resp = await self._http_client.get(
1242
+ f"{GRAPH_API_BASE}/{self._api_version}/{media_id}",
1243
+ headers=headers,
1244
+ )
1245
+ except Exception:
1246
+ logger.exception(
1247
+ "[whatsapp_cloud] media metadata fetch raised (id=%s)", media_id
1248
+ )
1249
+ return None, None
1250
+ if meta_resp.status_code != 200:
1251
+ logger.warning(
1252
+ "[whatsapp_cloud] media metadata fetch failed (id=%s, status=%d)",
1253
+ media_id, meta_resp.status_code,
1254
+ )
1255
+ return None, None
1256
+
1257
+ try:
1258
+ meta = meta_resp.json()
1259
+ except Exception:
1260
+ return None, None
1261
+ temp_url = meta.get("url")
1262
+ mime = meta.get("mime_type") or ""
1263
+ if not temp_url:
1264
+ return None, None
1265
+
1266
+ # Step 2 — bytes (auth required even though URL is signed; Meta
1267
+ # documents this explicitly — the URL alone is not enough).
1268
+ try:
1269
+ blob_resp = await self._http_client.get(temp_url, headers=headers)
1270
+ except Exception:
1271
+ logger.exception(
1272
+ "[whatsapp_cloud] media bytes fetch raised (id=%s)", media_id
1273
+ )
1274
+ return None, None
1275
+ if blob_resp.status_code != 200:
1276
+ logger.warning(
1277
+ "[whatsapp_cloud] media bytes fetch failed (id=%s, status=%d)",
1278
+ media_id, blob_resp.status_code,
1279
+ )
1280
+ return None, None
1281
+
1282
+ # Decide the extension. Prefer the override map so audio/ogg
1283
+ # produces .ogg (not the technically-correct-but-broken .oga
1284
+ # mimetypes returns by default). Fall back to ext_hint then
1285
+ # ``.bin`` for unknown types.
1286
+ ext = ext_hint
1287
+ if not ext and mime:
1288
+ ext = _ext_for_mime(mime)
1289
+ if not ext:
1290
+ ext = ".bin"
1291
+
1292
+ _INBOUND_MEDIA_CACHE.mkdir(parents=True, exist_ok=True)
1293
+ out_path = _INBOUND_MEDIA_CACHE / f"{media_id}{ext}"
1294
+ try:
1295
+ out_path.write_bytes(blob_resp.content)
1296
+ except OSError:
1297
+ logger.exception(
1298
+ "[whatsapp_cloud] failed to write cached media (id=%s)", media_id
1299
+ )
1300
+ return None, None
1301
+
1302
+ return str(out_path), mime or None
1303
+
1304
+
1305
+ # ------------------------------------------------------------------ inbound
1306
+ async def _handle_health(self, request: "web.Request") -> "web.Response":
1307
+ return web.json_response(
1308
+ {
1309
+ "status": "ok",
1310
+ "platform": self.platform.value,
1311
+ "phone_number_id": self._phone_number_id,
1312
+ "webhook_path": self._webhook_path,
1313
+ "verify_token_configured": bool(self._verify_token),
1314
+ "app_secret_configured": bool(self._app_secret),
1315
+ "ffmpeg_present": _FFMPEG_PATH is not None,
1316
+ "accepted": self._accepted_count,
1317
+ "duplicates": self._duplicate_count,
1318
+ "rejected_signature": self._rejected_signature_count,
1319
+ }
1320
+ )
1321
+
1322
+ async def _handle_verify(self, request: "web.Request") -> "web.Response":
1323
+ """Meta subscription verification handshake.
1324
+
1325
+ Meta calls GET ``<webhook>?hub.mode=subscribe&hub.verify_token=...
1326
+ &hub.challenge=...``. We must echo the challenge as plain text iff
1327
+ ``hub.mode == "subscribe"`` AND ``hub.verify_token`` matches the
1328
+ shared secret. Constant-time comparison.
1329
+ """
1330
+ if not self._verify_token:
1331
+ # Misconfigured server — refuse rather than silently accepting
1332
+ # any verify_token, which would let an attacker subscribe.
1333
+ return web.Response(status=503, text="verify_token not configured")
1334
+
1335
+ mode = request.query.get("hub.mode", "")
1336
+ token = request.query.get("hub.verify_token", "")
1337
+ challenge = request.query.get("hub.challenge", "")
1338
+
1339
+ if mode != "subscribe":
1340
+ return web.Response(status=400, text="bad mode")
1341
+
1342
+ # Constant-time compare to avoid token-length / token-content leaks
1343
+ # via timing. ``hmac.compare_digest`` works on str.
1344
+ import hmac as _hmac
1345
+
1346
+ if not _hmac.compare_digest(token, self._verify_token):
1347
+ return web.Response(status=403, text="verify_token mismatch")
1348
+ if not challenge:
1349
+ return web.Response(status=400, text="missing challenge")
1350
+ return web.Response(text=challenge, content_type="text/plain")
1351
+
1352
+ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
1353
+ """Inbound webhook POST handler.
1354
+
1355
+ Lifecycle:
1356
+ 1. Read raw bytes (signature is over the raw body — JSON parsing
1357
+ must NOT happen first, or the bytes change).
1358
+ 2. Verify ``X-Hub-Signature-256`` HMAC against ``app_secret``.
1359
+ 3. Parse JSON.
1360
+ 4. Walk ``entry[].changes[].value.{messages, statuses, contacts}``.
1361
+ 5. Per-message: dedup by wamid, build MessageEvent, dispatch via
1362
+ ``handle_message`` (which runs the mixin's gating).
1363
+ 6. Always respond 200 once we've ack'd a valid request — Meta
1364
+ retries on non-200 for up to 7 days, and we don't want to
1365
+ multiply downstream agent work because of a transient bug
1366
+ during dispatch.
1367
+ """
1368
+ try:
1369
+ raw = await request.read()
1370
+ except Exception:
1371
+ return web.Response(status=400)
1372
+
1373
+ # Meta's documented max payload is 3MB. Reject earlier than aiohttp
1374
+ # would so we don't even compute HMAC over giant junk.
1375
+ if len(raw) > 3 * 1024 * 1024:
1376
+ return web.Response(status=413)
1377
+
1378
+ # Refuse to accept anything if app_secret isn't configured. Without
1379
+ # it we can't authenticate the sender, and the handler would be a
1380
+ # data-injection point. Same defensive posture as the GET verify
1381
+ # handshake refusing when verify_token is empty.
1382
+ if not self._app_secret:
1383
+ logger.error(
1384
+ "[whatsapp_cloud] webhook POST refused: app_secret unset. "
1385
+ "Set WHATSAPP_CLOUD_APP_SECRET to enable inbound delivery."
1386
+ )
1387
+ return web.Response(status=503, text="app_secret not configured")
1388
+
1389
+ signature_header = request.headers.get("X-Hub-Signature-256", "")
1390
+ if not self._verify_signature(raw, signature_header):
1391
+ self._rejected_signature_count += 1
1392
+ logger.warning(
1393
+ "[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 "
1394
+ "(header=%r, body_len=%d)",
1395
+ signature_header,
1396
+ len(raw),
1397
+ )
1398
+ return web.Response(status=401)
1399
+
1400
+ # Parse only AFTER signature passes — bad JSON from an attacker is
1401
+ # already filtered out, this just guards against Meta sending
1402
+ # something malformed.
1403
+ import json as _json
1404
+
1405
+ try:
1406
+ payload = _json.loads(raw)
1407
+ except Exception:
1408
+ logger.warning("[whatsapp_cloud] webhook body is not valid JSON")
1409
+ return web.Response(status=400)
1410
+
1411
+ if not isinstance(payload, dict):
1412
+ return web.Response(status=400)
1413
+
1414
+ await self._dispatch_payload(payload)
1415
+ return web.Response(status=200)
1416
+
1417
+ # ------------------------------------------------------------------ signature
1418
+ def _verify_signature(self, raw_body: bytes, header: str) -> bool:
1419
+ """Verify the X-Hub-Signature-256 HMAC.
1420
+
1421
+ Meta sends ``sha256=<hex>``; we compute the same HMAC with
1422
+ ``app_secret`` as the key and ``raw_body`` (UTF-8 bytes, not
1423
+ re-serialized JSON) as the message. Constant-time compare.
1424
+ """
1425
+ if not self._app_secret or not header:
1426
+ return False
1427
+ if not header.startswith("sha256="):
1428
+ return False
1429
+ expected_hex = header[len("sha256="):].strip()
1430
+ if not expected_hex:
1431
+ return False
1432
+ computed = hmac.new(
1433
+ self._app_secret.encode("utf-8"),
1434
+ raw_body,
1435
+ hashlib.sha256,
1436
+ ).hexdigest()
1437
+ return hmac.compare_digest(computed.lower(), expected_hex.lower())
1438
+
1439
+ # ------------------------------------------------------------------ dispatch
1440
+ def _dedup_wamid(self, wamid: str) -> bool:
1441
+ """Return True if this wamid is being seen for the first time.
1442
+
1443
+ Returns False (and increments duplicate counter) if the wamid is
1444
+ already in the in-memory cache. Cache is FIFO-evicted at
1445
+ ``WAMID_DEDUP_CACHE_SIZE``.
1446
+ """
1447
+ if not wamid:
1448
+ # No wamid means we can't dedup — let it through. Meta should
1449
+ # always populate ``id``, but be defensive.
1450
+ return True
1451
+ if wamid in self._seen_wamids:
1452
+ self._duplicate_count += 1
1453
+ return False
1454
+ self._seen_wamids[wamid] = True
1455
+ # Trim oldest entries to stay under the cap.
1456
+ while len(self._seen_wamids) > WAMID_DEDUP_CACHE_SIZE:
1457
+ self._seen_wamids.popitem(last=False)
1458
+ return True
1459
+
1460
+ async def _dispatch_payload(self, payload: Dict[str, Any]) -> None:
1461
+ """Walk a verified Meta webhook payload and dispatch each message.
1462
+
1463
+ Payload shape (truncated):
1464
+ {object, entry: [{id, changes: [{value: {messages, contacts,
1465
+ statuses, metadata}, field: "messages"}]}]}
1466
+
1467
+ We surface ``messages`` events as MessageEvents; ``statuses``
1468
+ events (sent/delivered/read/failed) are logged but not dispatched
1469
+ — the agent doesn't currently consume delivery receipts and
1470
+ forwarding them would create noisy synthetic events.
1471
+ """
1472
+ if payload.get("object") != "whatsapp_business_account":
1473
+ logger.debug(
1474
+ "[whatsapp_cloud] ignoring non-WABA payload (object=%r)",
1475
+ payload.get("object"),
1476
+ )
1477
+ return
1478
+ for entry in payload.get("entry") or []:
1479
+ if not isinstance(entry, dict):
1480
+ continue
1481
+ for change in entry.get("changes") or []:
1482
+ if not isinstance(change, dict):
1483
+ continue
1484
+ if change.get("field") != "messages":
1485
+ # Other fields (account_alerts, template_status_update,
1486
+ # etc.) are subscription-dependent and not message
1487
+ # ingress. Silent skip.
1488
+ continue
1489
+ value = change.get("value") or {}
1490
+ contacts = value.get("contacts") or []
1491
+ metadata = value.get("metadata") or {}
1492
+ # Build a wa_id → profile-name index for the messages we're
1493
+ # about to surface.
1494
+ contacts_by_waid: Dict[str, str] = {}
1495
+ for contact in contacts:
1496
+ if not isinstance(contact, dict):
1497
+ continue
1498
+ wa_id = str(contact.get("wa_id") or "").strip()
1499
+ profile = contact.get("profile") or {}
1500
+ name = str(profile.get("name") or "").strip()
1501
+ if wa_id:
1502
+ contacts_by_waid[wa_id] = name
1503
+
1504
+ for raw_message in value.get("messages") or []:
1505
+ if not isinstance(raw_message, dict):
1506
+ continue
1507
+ wamid = str(raw_message.get("id") or "").strip()
1508
+ if not self._dedup_wamid(wamid):
1509
+ logger.debug(
1510
+ "[whatsapp_cloud] duplicate wamid %s, skipping",
1511
+ wamid,
1512
+ )
1513
+ continue
1514
+ try:
1515
+ event = await self._build_message_event_from_cloud(
1516
+ raw_message, contacts_by_waid, metadata
1517
+ )
1518
+ except Exception:
1519
+ # Build errors must not bubble out either: the wamid
1520
+ # is already dedup-marked above, so a 500 here would
1521
+ # make Meta retry the batch and every message in it
1522
+ # (including this one) would be silently dropped as
1523
+ # a duplicate. Log and move on to the next message.
1524
+ logger.exception(
1525
+ "[whatsapp_cloud] failed to build event for wamid %s",
1526
+ wamid,
1527
+ )
1528
+ continue
1529
+ if event is None:
1530
+ continue
1531
+ self._accepted_count += 1
1532
+ try:
1533
+ await self.handle_message(event)
1534
+ except Exception:
1535
+ # Dispatch errors must not bubble out — Meta would
1536
+ # retry the whole batch, multiplying the bug.
1537
+ logger.exception(
1538
+ "[whatsapp_cloud] handle_message raised for wamid %s",
1539
+ wamid,
1540
+ )
1541
+
1542
+ # Log status updates at debug level — useful for diagnosing
1543
+ # "did Meta accept my outbound" without flooding INFO logs.
1544
+ for status in value.get("statuses") or []:
1545
+ if isinstance(status, dict):
1546
+ logger.debug(
1547
+ "[whatsapp_cloud] status %s for %s",
1548
+ status.get("status"),
1549
+ status.get("id"),
1550
+ )
1551
+
1552
+ async def _dispatch_interactive_reply(
1553
+ self,
1554
+ raw_message: Dict[str, Any],
1555
+ contacts_by_waid: Dict[str, str],
1556
+ ) -> bool:
1557
+ """Route an inbound interactive reply to the matching resolver.
1558
+
1559
+ Returns True if the tap was claimed (caller should drop the
1560
+ webhook entry without dispatching a fresh conversation turn).
1561
+ Returns False when the id has no recognized prefix, no live
1562
+ state entry, or the resolver itself reports no waiter — in
1563
+ those cases the caller falls back to standard text-event
1564
+ dispatch, which treats the button title as a normal user
1565
+ message. That graceful fallback covers stale-tap and
1566
+ cross-process-restart scenarios.
1567
+
1568
+ Dispatch table:
1569
+ ``cl:<clarify_id>:<idx|other>`` → resolve_gateway_clarify
1570
+ ``appr:<approval_id>:approve|deny`` → resolve_gateway_approval
1571
+ ``sc:<once|always|cancel>:<confirm_id>`` → slash_confirm.resolve
1572
+ """
1573
+ inter = raw_message.get("interactive") or {}
1574
+ # button_reply (interactive.type=button) and list_reply
1575
+ # (interactive.type=list) carry id+title in different sub-objects.
1576
+ inner = inter.get("button_reply") or inter.get("list_reply") or {}
1577
+ button_id = str(inner.get("id") or "").strip()
1578
+ if not button_id:
1579
+ return False
1580
+
1581
+ # Clarify: cl:<clarify_id>:<idx|other>
1582
+ if button_id.startswith("cl:"):
1583
+ parts = button_id.split(":", 2)
1584
+ if len(parts) != 3:
1585
+ return False
1586
+ _, clarify_id, choice = parts
1587
+ session_key = self._clarify_state.pop(clarify_id, None)
1588
+ if not session_key:
1589
+ logger.info(
1590
+ "[whatsapp_cloud] clarify tap with no matching state "
1591
+ "(clarify_id=%s) — likely stale; falling back to text",
1592
+ clarify_id,
1593
+ )
1594
+ return False
1595
+ try:
1596
+ from tools.clarify_gateway import resolve_gateway_clarify
1597
+ except ImportError:
1598
+ logger.warning(
1599
+ "[whatsapp_cloud] clarify resolver unavailable; "
1600
+ "falling back to text dispatch"
1601
+ )
1602
+ return False
1603
+ if choice == "other":
1604
+ # User wants to type a free-form answer. Flip the entry
1605
+ # into text-capture mode so the gateway's text-intercept
1606
+ # (in _handle_message) picks up their next message and
1607
+ # resolves the clarify. Without this flip,
1608
+ # ``get_pending_for_session`` won't return the entry —
1609
+ # the next text would fall through to the regular agent
1610
+ # path, which collides with the agent thread still
1611
+ # blocked in clarify and produces an "Interrupting
1612
+ # current task" loop.
1613
+ try:
1614
+ from tools.clarify_gateway import mark_awaiting_text
1615
+ flipped = mark_awaiting_text(clarify_id)
1616
+ except Exception:
1617
+ logger.exception(
1618
+ "[whatsapp_cloud] mark_awaiting_text failed for %s",
1619
+ clarify_id,
1620
+ )
1621
+ flipped = False
1622
+ if not flipped:
1623
+ # Entry vanished between the user tap and our handler
1624
+ # (timeout, /new, gateway restart). Drop the stale
1625
+ # state and fall through to text dispatch so the
1626
+ # user's tap isn't completely ignored.
1627
+ logger.info(
1628
+ "[whatsapp_cloud] clarify 'Other' tap but entry "
1629
+ "missing (clarify_id=%s); falling back to text",
1630
+ clarify_id,
1631
+ )
1632
+ return False
1633
+ # Put state back since we popped it earlier — keep the
1634
+ # clarify_id → session_key mapping live in case future
1635
+ # taps land on the same prompt.
1636
+ self._clarify_state[clarify_id] = session_key
1637
+ try:
1638
+ await self.send(
1639
+ str(raw_message.get("from") or ""),
1640
+ "✏️ Type your answer:",
1641
+ )
1642
+ except Exception:
1643
+ logger.exception("[whatsapp_cloud] clarify other-prompt failed")
1644
+ return True # claim so we don't also dispatch the tap as text
1645
+ try:
1646
+ idx = int(choice)
1647
+ except ValueError:
1648
+ logger.warning(
1649
+ "[whatsapp_cloud] clarify tap had non-int choice: %r",
1650
+ choice,
1651
+ )
1652
+ # Put state back so a follow-up text can still resolve.
1653
+ self._clarify_state[clarify_id] = session_key
1654
+ return False
1655
+ # Use the title text as the resolved response so the agent
1656
+ # sees the human-readable answer, not the index. Title is
1657
+ # the numeric label ("1", "2", ...) so we look up the
1658
+ # full choice from the original prompt — but we didn't
1659
+ # persist that. Fall back to passing the index; the agent
1660
+ # has the prompt in context and can interpret it.
1661
+ response_text = str(inner.get("title") or str(idx + 1))
1662
+ resolved = resolve_gateway_clarify(clarify_id, response_text)
1663
+ if not resolved:
1664
+ # Resolver couldn't find a waiter (e.g. agent already
1665
+ # timed out). Fall through to text dispatch.
1666
+ logger.info(
1667
+ "[whatsapp_cloud] clarify resolver reported no waiter "
1668
+ "(clarify_id=%s) — falling back to text", clarify_id,
1669
+ )
1670
+ return False
1671
+ return True
1672
+
1673
+ # Exec approval: appr:<approval_id>:approve|deny
1674
+ if button_id.startswith("appr:"):
1675
+ parts = button_id.split(":", 2)
1676
+ if len(parts) != 3:
1677
+ return False
1678
+ _, approval_id, choice = parts
1679
+ session_key = self._exec_approval_state.pop(approval_id, None)
1680
+ if not session_key:
1681
+ logger.info(
1682
+ "[whatsapp_cloud] approval tap with no matching state "
1683
+ "(approval_id=%s) — likely stale; falling back to text",
1684
+ approval_id,
1685
+ )
1686
+ return False
1687
+ if choice not in ("approve", "deny"):
1688
+ self._exec_approval_state[approval_id] = session_key
1689
+ return False
1690
+ try:
1691
+ from tools.approval import resolve_gateway_approval
1692
+ except ImportError:
1693
+ logger.warning(
1694
+ "[whatsapp_cloud] approval resolver unavailable"
1695
+ )
1696
+ return False
1697
+ count = resolve_gateway_approval(session_key, choice)
1698
+ if not count:
1699
+ logger.info(
1700
+ "[whatsapp_cloud] approval resolver reported no waiter "
1701
+ "(session_key=%s) — likely already resolved",
1702
+ session_key,
1703
+ )
1704
+ # Send confirmation message — paralleling Telegram's UX.
1705
+ try:
1706
+ confirm_text = (
1707
+ "✅ Approved." if choice == "approve" else "❌ Denied."
1708
+ )
1709
+ await self.send(str(raw_message.get("from") or ""), confirm_text)
1710
+ except Exception:
1711
+ logger.exception("[whatsapp_cloud] approval confirm failed")
1712
+ return True
1713
+
1714
+ # Slash confirm: sc:<once|always|cancel>:<confirm_id>
1715
+ if button_id.startswith("sc:"):
1716
+ parts = button_id.split(":", 2)
1717
+ if len(parts) != 3:
1718
+ return False
1719
+ _, choice, confirm_id = parts
1720
+ session_key = self._slash_confirm_state.pop(confirm_id, None)
1721
+ if not session_key:
1722
+ logger.info(
1723
+ "[whatsapp_cloud] slash_confirm tap with no matching state "
1724
+ "(confirm_id=%s) — likely stale", confirm_id,
1725
+ )
1726
+ return False
1727
+ if choice not in ("once", "always", "cancel"):
1728
+ self._slash_confirm_state[confirm_id] = session_key
1729
+ return False
1730
+ try:
1731
+ from tools import slash_confirm as _slash_confirm_mod
1732
+ except ImportError:
1733
+ logger.warning(
1734
+ "[whatsapp_cloud] slash_confirm resolver unavailable"
1735
+ )
1736
+ return False
1737
+ try:
1738
+ result_text = await _slash_confirm_mod.resolve(
1739
+ session_key, confirm_id, choice
1740
+ )
1741
+ except Exception:
1742
+ logger.exception("[whatsapp_cloud] slash_confirm.resolve failed")
1743
+ return True # still claim the tap; surfacing it as text wouldn't help
1744
+ if result_text:
1745
+ try:
1746
+ await self.send(str(raw_message.get("from") or ""), result_text)
1747
+ except Exception:
1748
+ logger.exception("[whatsapp_cloud] slash_confirm reply failed")
1749
+ return True
1750
+
1751
+ # Unknown prefix — let text dispatch handle the title as a
1752
+ # regular message. Could be a tap from a plugin-defined adapter
1753
+ # we don't know about; treating it as text is the safe default.
1754
+ return False
1755
+
1756
+ async def _build_message_event_from_cloud(
1757
+ self,
1758
+ raw_message: Dict[str, Any],
1759
+ contacts_by_waid: Dict[str, str],
1760
+ metadata: Dict[str, Any],
1761
+ ) -> Optional[MessageEvent]:
1762
+ """Convert a Cloud-API message object into a Hermes MessageEvent.
1763
+
1764
+ Phase 4 expands beyond text to download inbound media (image,
1765
+ video, audio/voice, document, sticker) by ``media_id`` via the
1766
+ two-step Graph endpoint. Cached files are populated into
1767
+ ``media_urls`` / ``media_types`` so the agent's vision and STT
1768
+ layers see them. Text-readable documents (.txt, .md, .json,
1769
+ source code, etc.) are read and prepended to the message body
1770
+ up to 100KB — same heuristic the Baileys adapter uses.
1771
+
1772
+ Returns None if the message is filtered out by the mixin's
1773
+ gating (broadcast filter, allow-list, mention requirements).
1774
+ """
1775
+ msg_type_str = str(raw_message.get("type") or "text").lower()
1776
+
1777
+ # Interactive replies (button taps, list selections) carry an ``id``
1778
+ # we set when sending the prompt. Route those to the appropriate
1779
+ # gateway resolver BEFORE falling through to text dispatch — the
1780
+ # resolver unblocks the waiting agent thread, so we don't want to
1781
+ # also kick a fresh conversation turn off the same tap.
1782
+ if msg_type_str == "interactive":
1783
+ handled = await self._dispatch_interactive_reply(
1784
+ raw_message, contacts_by_waid
1785
+ )
1786
+ if handled:
1787
+ return None
1788
+
1789
+ body = ""
1790
+ if msg_type_str == "text":
1791
+ text = raw_message.get("text") or {}
1792
+ body = str(text.get("body") or "")
1793
+ elif msg_type_str in {"button", "interactive"}:
1794
+ # Quick-reply buttons. Treat the button payload as text so the
1795
+ # agent can reason about the user's choice.
1796
+ if msg_type_str == "button":
1797
+ body = str((raw_message.get("button") or {}).get("text") or "")
1798
+ else:
1799
+ inter = raw_message.get("interactive") or {}
1800
+ # button_reply / list_reply both expose ``title``
1801
+ inner = inter.get("button_reply") or inter.get("list_reply") or {}
1802
+ body = str(inner.get("title") or "")
1803
+ elif msg_type_str in {"image", "video", "audio", "voice", "document", "sticker"}:
1804
+ # Captions live on image / video / document. Other media types
1805
+ # don't carry a caption in Meta's spec, but be defensive.
1806
+ inner = raw_message.get(msg_type_str) or {}
1807
+ body = str(inner.get("caption") or "")
1808
+
1809
+ message_type = {
1810
+ "text": MessageType.TEXT,
1811
+ "image": MessageType.PHOTO,
1812
+ "video": MessageType.VIDEO,
1813
+ "audio": MessageType.VOICE,
1814
+ "voice": MessageType.VOICE,
1815
+ "document": MessageType.DOCUMENT,
1816
+ "sticker": MessageType.PHOTO,
1817
+ "button": MessageType.TEXT,
1818
+ "interactive": MessageType.TEXT,
1819
+ "location": MessageType.TEXT,
1820
+ "contacts": MessageType.TEXT,
1821
+ }.get(msg_type_str, MessageType.TEXT)
1822
+
1823
+ sender_id = str(raw_message.get("from") or "").strip()
1824
+ sender_name = contacts_by_waid.get(sender_id, "")
1825
+
1826
+ # Cloud API doesn't have a separate "chat" entity for DMs — chat_id
1827
+ # equals the sender's wa_id. Group support is deferred to v2.
1828
+ #
1829
+ # Defensive guard: if Meta ever delivers a group-shaped payload
1830
+ # (group support is capability-tier gated by Meta; some WABAs
1831
+ # have it enabled), refuse rather than silently treating it as
1832
+ # a DM. Group messages carry a ``chat`` field on the message
1833
+ # object identifying the group JID — its absence signals DM.
1834
+ chat_field = raw_message.get("chat")
1835
+ if chat_field:
1836
+ logger.warning(
1837
+ "[whatsapp_cloud] received group-shaped message (chat=%s, "
1838
+ "wamid=%s) — group support is not yet implemented; dropping. "
1839
+ "Use the Baileys whatsapp adapter for group chats.",
1840
+ chat_field, raw_message.get("id"),
1841
+ )
1842
+ return None
1843
+
1844
+ chat_id = sender_id
1845
+
1846
+ # Build the data dict the mixin's _should_process_message expects.
1847
+ # Cloud API uses different field names from Baileys, so we adapt.
1848
+ gating_data = {
1849
+ "chatId": chat_id,
1850
+ "senderId": sender_id,
1851
+ "isGroup": False, # Phase 3 = DM only
1852
+ "body": body,
1853
+ }
1854
+ if not self._should_process_message(gating_data):
1855
+ return None
1856
+
1857
+ # Download media if this is a non-text message type. Inbound media
1858
+ # arrives as ``{type: "image", image: {id, mime_type, sha256, ...}}``.
1859
+ media_urls: list[str] = []
1860
+ media_types: list[str] = []
1861
+ if msg_type_str in {"image", "video", "audio", "voice", "document", "sticker"}:
1862
+ inner = raw_message.get(msg_type_str) or {}
1863
+ media_id = str(inner.get("id") or "").strip()
1864
+ inbound_mime = str(inner.get("mime_type") or "").strip()
1865
+ if media_id:
1866
+ ext_hint = None
1867
+ if inbound_mime:
1868
+ ext_hint = _ext_for_mime(inbound_mime)
1869
+ local_path, dl_mime = await self._download_media_to_cache(
1870
+ media_id, ext_hint=ext_hint
1871
+ )
1872
+ if local_path:
1873
+ media_urls.append(local_path)
1874
+ media_types.append(dl_mime or inbound_mime or "application/octet-stream")
1875
+ logger.info(
1876
+ "[whatsapp_cloud] cached inbound %s media: %s",
1877
+ msg_type_str, local_path,
1878
+ )
1879
+ else:
1880
+ logger.warning(
1881
+ "[whatsapp_cloud] failed to download inbound %s (id=%s) — "
1882
+ "agent will see message metadata but not the binary",
1883
+ msg_type_str, media_id,
1884
+ )
1885
+ # Document: original filename for the agent's UX.
1886
+ if msg_type_str == "document":
1887
+ fname = str(inner.get("filename") or "").strip()
1888
+ if fname and not body:
1889
+ body = f"[Document: {fname}]"
1890
+
1891
+ # For text-readable documents, inject the file content directly into
1892
+ # the message body so the agent can reason about it without a
1893
+ # separate read_file call. Same heuristic the Baileys adapter uses.
1894
+ # 100KB cap matches Telegram/Discord/Slack.
1895
+ MAX_TEXT_INJECT_BYTES = 100 * 1024
1896
+ if msg_type_str == "document" and media_urls:
1897
+ for doc_path in media_urls:
1898
+ ext = Path(doc_path).suffix.lower()
1899
+ if ext in {
1900
+ ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml",
1901
+ ".log", ".py", ".js", ".ts", ".html", ".css",
1902
+ }:
1903
+ try:
1904
+ file_size = Path(doc_path).stat().st_size
1905
+ if file_size > MAX_TEXT_INJECT_BYTES:
1906
+ logger.info(
1907
+ "[whatsapp_cloud] skipping text injection for %s "
1908
+ "(%d bytes > %d)",
1909
+ doc_path, file_size, MAX_TEXT_INJECT_BYTES,
1910
+ )
1911
+ continue
1912
+ content = Path(doc_path).read_text(
1913
+ encoding="utf-8", errors="replace"
1914
+ )
1915
+ display_name = Path(doc_path).name
1916
+ injection = f"[Content of {display_name}]:\n{content}"
1917
+ body = f"{injection}\n\n{body}" if body else injection
1918
+ except OSError:
1919
+ logger.exception(
1920
+ "[whatsapp_cloud] failed to read document text: %s",
1921
+ doc_path,
1922
+ )
1923
+
1924
+ # context.id is set when the user replied to one of our messages.
1925
+ context = raw_message.get("context") or {}
1926
+ reply_to_id = str(context.get("id") or "").strip() or None
1927
+
1928
+ source = self.build_source(
1929
+ chat_id=chat_id,
1930
+ chat_name=sender_name or chat_id,
1931
+ chat_type="dm",
1932
+ user_id=sender_id,
1933
+ user_name=sender_name or None,
1934
+ )
1935
+
1936
+ # Cloud API timestamps are unix seconds (string). MessageEvent
1937
+ # doesn't enforce a type but downstream code formats with it.
1938
+ wamid = str(raw_message.get("id") or "") or None
1939
+ if wamid and chat_id:
1940
+ # Refresh the per-chat latest-wamid cache so a subsequent
1941
+ # send_typing call can attach the indicator + read receipt
1942
+ # to this message. Done HERE (after _should_process_message
1943
+ # gating) so filtered messages don't leak typing on
1944
+ # unwanted inbound traffic.
1945
+ self._bounded_put(self._last_inbound_wamid_by_chat, chat_id, wamid)
1946
+
1947
+ return MessageEvent(
1948
+ text=body,
1949
+ message_type=message_type,
1950
+ source=source,
1951
+ raw_message=raw_message,
1952
+ message_id=wamid,
1953
+ reply_to_message_id=reply_to_id,
1954
+ media_urls=media_urls,
1955
+ media_types=media_types,
1956
+ )