@clawpump/claw-agent 0.1.4 → 0.1.6

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 (1214) 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 +2294 -3146
  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/clawpump_cli.py +3 -3
  586. package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
  587. package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
  588. package/agent/hermes_cli/commands.py +216 -91
  589. package/agent/hermes_cli/config.py +967 -130
  590. package/agent/hermes_cli/container_boot.py +76 -11
  591. package/agent/hermes_cli/cron.py +5 -11
  592. package/agent/hermes_cli/curator.py +21 -0
  593. package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
  594. package/agent/hermes_cli/dashboard_auth/base.py +62 -0
  595. package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
  596. package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
  597. package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
  598. package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
  599. package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
  600. package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
  601. package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
  602. package/agent/hermes_cli/dashboard_register.py +427 -0
  603. package/agent/hermes_cli/debug.py +155 -50
  604. package/agent/hermes_cli/distribution.py +227 -0
  605. package/agent/hermes_cli/doctor.py +255 -14
  606. package/agent/hermes_cli/dump.py +60 -6
  607. package/agent/hermes_cli/env_loader.py +33 -0
  608. package/agent/hermes_cli/gateway.py +755 -103
  609. package/agent/hermes_cli/gateway_enroll.py +250 -0
  610. package/agent/hermes_cli/gateway_windows.py +254 -11
  611. package/agent/hermes_cli/gui_uninstall.py +285 -0
  612. package/agent/hermes_cli/inventory.py +105 -4
  613. package/agent/hermes_cli/kanban.py +58 -71
  614. package/agent/hermes_cli/kanban_db.py +391 -14
  615. package/agent/hermes_cli/kanban_decompose.py +2 -2
  616. package/agent/hermes_cli/kanban_specify.py +3 -1
  617. package/agent/hermes_cli/logs.py +2 -0
  618. package/agent/hermes_cli/main.py +2889 -5287
  619. package/agent/hermes_cli/managed_scope.py +214 -0
  620. package/agent/hermes_cli/managed_uv.py +254 -0
  621. package/agent/hermes_cli/mcp_catalog.py +6 -3
  622. package/agent/hermes_cli/mcp_config.py +145 -21
  623. package/agent/hermes_cli/mcp_security.py +96 -0
  624. package/agent/hermes_cli/mcp_startup.py +32 -3
  625. package/agent/hermes_cli/memory_providers.py +149 -0
  626. package/agent/hermes_cli/memory_setup.py +97 -42
  627. package/agent/hermes_cli/middleware.py +313 -0
  628. package/agent/hermes_cli/model_catalog.py +31 -0
  629. package/agent/hermes_cli/model_cost_guard.py +134 -0
  630. package/agent/hermes_cli/model_normalize.py +2 -1
  631. package/agent/hermes_cli/model_setup_flows.py +2759 -0
  632. package/agent/hermes_cli/model_switch.py +242 -27
  633. package/agent/hermes_cli/models.py +284 -44
  634. package/agent/hermes_cli/nous_account.py +33 -6
  635. package/agent/hermes_cli/nous_billing.py +406 -0
  636. package/agent/hermes_cli/nous_subscription.py +202 -5
  637. package/agent/hermes_cli/platforms.py +1 -0
  638. package/agent/hermes_cli/plugins.py +218 -18
  639. package/agent/hermes_cli/plugins_cmd.py +249 -105
  640. package/agent/hermes_cli/portal_cli.py +56 -16
  641. package/agent/hermes_cli/profile_distribution.py +6 -1
  642. package/agent/hermes_cli/profiles.py +283 -32
  643. package/agent/hermes_cli/provider_catalog.py +170 -0
  644. package/agent/hermes_cli/providers.py +4 -1
  645. package/agent/hermes_cli/pty_bridge.py +53 -4
  646. package/agent/hermes_cli/runtime_provider.py +216 -34
  647. package/agent/hermes_cli/secret_prompt.py +4 -4
  648. package/agent/hermes_cli/secrets_cli.py +24 -0
  649. package/agent/hermes_cli/send_cmd.py +28 -2
  650. package/agent/hermes_cli/service_manager.py +166 -19
  651. package/agent/hermes_cli/session_listing.py +97 -0
  652. package/agent/hermes_cli/setup.py +158 -94
  653. package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
  654. package/agent/hermes_cli/skills_config.py +8 -2
  655. package/agent/hermes_cli/skills_hub.py +149 -7
  656. package/agent/hermes_cli/status.py +2 -2
  657. package/agent/hermes_cli/subcommands/__init__.py +18 -0
  658. package/agent/hermes_cli/subcommands/_shared.py +29 -0
  659. package/agent/hermes_cli/subcommands/acp.py +52 -0
  660. package/agent/hermes_cli/subcommands/auth.py +109 -0
  661. package/agent/hermes_cli/subcommands/backup.py +38 -0
  662. package/agent/hermes_cli/subcommands/claw.py +92 -0
  663. package/agent/hermes_cli/subcommands/config.py +49 -0
  664. package/agent/hermes_cli/subcommands/cron.py +163 -0
  665. package/agent/hermes_cli/subcommands/dashboard.py +143 -0
  666. package/agent/hermes_cli/subcommands/debug.py +77 -0
  667. package/agent/hermes_cli/subcommands/doctor.py +35 -0
  668. package/agent/hermes_cli/subcommands/dump.py +28 -0
  669. package/agent/hermes_cli/subcommands/gateway.py +332 -0
  670. package/agent/hermes_cli/subcommands/gui.py +63 -0
  671. package/agent/hermes_cli/subcommands/hooks.py +77 -0
  672. package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
  673. package/agent/hermes_cli/subcommands/insights.py +25 -0
  674. package/agent/hermes_cli/subcommands/login.py +78 -0
  675. package/agent/hermes_cli/subcommands/logout.py +28 -0
  676. package/agent/hermes_cli/subcommands/logs.py +78 -0
  677. package/agent/hermes_cli/subcommands/mcp.py +108 -0
  678. package/agent/hermes_cli/subcommands/memory.py +53 -0
  679. package/agent/hermes_cli/subcommands/model.py +72 -0
  680. package/agent/hermes_cli/subcommands/pairing.py +36 -0
  681. package/agent/hermes_cli/subcommands/plugins.py +94 -0
  682. package/agent/hermes_cli/subcommands/postinstall.py +23 -0
  683. package/agent/hermes_cli/subcommands/profile.py +203 -0
  684. package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
  685. package/agent/hermes_cli/subcommands/security.py +62 -0
  686. package/agent/hermes_cli/subcommands/setup.py +58 -0
  687. package/agent/hermes_cli/subcommands/skills.py +298 -0
  688. package/agent/hermes_cli/subcommands/slack.py +60 -0
  689. package/agent/hermes_cli/subcommands/status.py +28 -0
  690. package/agent/hermes_cli/subcommands/tools.py +95 -0
  691. package/agent/hermes_cli/subcommands/uninstall.py +41 -0
  692. package/agent/hermes_cli/subcommands/update.py +70 -0
  693. package/agent/hermes_cli/subcommands/version.py +18 -0
  694. package/agent/hermes_cli/subcommands/webhook.py +76 -0
  695. package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
  696. package/agent/hermes_cli/suggestions_cmd.py +153 -0
  697. package/agent/hermes_cli/telegram_managed_bot.py +358 -0
  698. package/agent/hermes_cli/tips.py +3 -4
  699. package/agent/hermes_cli/tools_config.py +155 -28
  700. package/agent/hermes_cli/uninstall.py +231 -35
  701. package/agent/hermes_cli/web_server.py +6188 -975
  702. package/agent/hermes_cli/win_pty_bridge.py +179 -0
  703. package/agent/hermes_cli/write_approval_commands.py +209 -0
  704. package/agent/hermes_constants.py +164 -33
  705. package/agent/hermes_logging.py +74 -2
  706. package/agent/hermes_state.py +919 -106
  707. package/agent/hermes_time.py +20 -0
  708. package/agent/locales/af.yaml +23 -0
  709. package/agent/locales/de.yaml +23 -0
  710. package/agent/locales/en.yaml +20 -0
  711. package/agent/locales/es.yaml +23 -0
  712. package/agent/locales/fr.yaml +23 -0
  713. package/agent/locales/ga.yaml +23 -0
  714. package/agent/locales/hu.yaml +23 -0
  715. package/agent/locales/it.yaml +23 -0
  716. package/agent/locales/ja.yaml +23 -0
  717. package/agent/locales/ko.yaml +23 -0
  718. package/agent/locales/pt.yaml +23 -0
  719. package/agent/locales/ru.yaml +23 -0
  720. package/agent/locales/tr.yaml +23 -0
  721. package/agent/locales/uk.yaml +23 -0
  722. package/agent/locales/zh-hant.yaml +23 -0
  723. package/agent/locales/zh.yaml +23 -0
  724. package/agent/model_tools.py +204 -40
  725. package/agent/optional-mcps/clawpump/manifest.yaml +15 -5
  726. package/agent/optional-mcps/clawpump-stdio/manifest.yaml +14 -4
  727. package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
  728. package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
  729. package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
  730. package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
  731. package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
  732. package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
  733. package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
  734. package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
  735. package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
  736. package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
  737. package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
  738. package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
  739. package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
  740. package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
  741. package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
  742. package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
  743. package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
  744. package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
  745. package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
  746. package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
  747. package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
  748. package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
  749. package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
  750. package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
  751. package/agent/optional-skills/security/1password/SKILL.md +1 -1
  752. package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
  753. package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
  754. package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
  755. package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
  756. package/agent/package-lock.json +4082 -7907
  757. package/agent/package.json +18 -3
  758. package/agent/plugins/browser/firecrawl/provider.py +4 -1
  759. package/agent/plugins/cron/__init__.py +344 -0
  760. package/agent/plugins/cron/chronos/__init__.py +241 -0
  761. package/agent/plugins/cron/chronos/_nas_client.py +123 -0
  762. package/agent/plugins/cron/chronos/plugin.yaml +9 -0
  763. package/agent/plugins/cron/chronos/verify.py +103 -0
  764. package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
  765. package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
  766. package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
  767. package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
  768. package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
  769. package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
  770. package/agent/plugins/google_meet/audio_bridge.py +4 -0
  771. package/agent/plugins/google_meet/meet_bot.py +7 -1
  772. package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
  773. package/agent/plugins/image_gen/fal/__init__.py +35 -6
  774. package/agent/plugins/image_gen/krea/__init__.py +56 -13
  775. package/agent/plugins/image_gen/openai/__init__.py +122 -24
  776. package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
  777. package/agent/plugins/image_gen/xai/__init__.py +92 -12
  778. package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
  779. package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
  780. package/agent/plugins/memory/__init__.py +48 -5
  781. package/agent/plugins/memory/byterover/__init__.py +1 -0
  782. package/agent/plugins/memory/hindsight/README.md +1 -1
  783. package/agent/plugins/memory/hindsight/__init__.py +138 -24
  784. package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
  785. package/agent/plugins/memory/honcho/README.md +13 -10
  786. package/agent/plugins/memory/honcho/cli.py +247 -122
  787. package/agent/plugins/memory/honcho/client.py +112 -102
  788. package/agent/plugins/memory/openviking/README.md +12 -1
  789. package/agent/plugins/memory/openviking/__init__.py +2281 -107
  790. package/agent/plugins/memory/openviking/plugin.yaml +1 -2
  791. package/agent/plugins/memory/supermemory/README.md +22 -10
  792. package/agent/plugins/memory/supermemory/__init__.py +142 -37
  793. package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
  794. package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
  795. package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
  796. package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
  797. package/agent/plugins/model-providers/custom/__init__.py +8 -2
  798. package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
  799. package/agent/plugins/model-providers/minimax/__init__.py +60 -8
  800. package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
  801. package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
  802. package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
  803. package/agent/plugins/model-providers/zai/__init__.py +1 -0
  804. package/agent/plugins/observability/langfuse/__init__.py +147 -14
  805. package/agent/plugins/observability/nemo_relay/README.md +559 -0
  806. package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
  807. package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
  808. package/agent/plugins/platforms/discord/adapter.py +932 -61
  809. package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
  810. package/agent/plugins/platforms/google_chat/adapter.py +9 -3
  811. package/agent/plugins/platforms/google_chat/oauth.py +1 -1
  812. package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
  813. package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
  814. package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
  815. package/agent/plugins/platforms/irc/adapter.py +4 -1
  816. package/agent/plugins/platforms/line/adapter.py +16 -1
  817. package/agent/plugins/platforms/mattermost/adapter.py +100 -24
  818. package/agent/plugins/platforms/photon/README.md +179 -0
  819. package/agent/plugins/platforms/photon/__init__.py +4 -0
  820. package/agent/plugins/platforms/photon/adapter.py +1586 -0
  821. package/agent/plugins/platforms/photon/auth.py +1046 -0
  822. package/agent/plugins/platforms/photon/cli.py +439 -0
  823. package/agent/plugins/platforms/photon/plugin.yaml +88 -0
  824. package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
  825. package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
  826. package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
  827. package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
  828. package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
  829. package/agent/plugins/platforms/raft/__init__.py +3 -0
  830. package/agent/plugins/platforms/raft/adapter.py +774 -0
  831. package/agent/plugins/platforms/raft/plugin.yaml +19 -0
  832. package/agent/plugins/platforms/simplex/adapter.py +777 -220
  833. package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
  834. package/agent/plugins/platforms/teams/adapter.py +175 -5
  835. package/agent/plugins/plugin_utils.py +135 -0
  836. package/agent/plugins/video_gen/fal/__init__.py +10 -3
  837. package/agent/plugins/web/searxng/provider.py +15 -2
  838. package/agent/plugins/web/xai/provider.py +2 -2
  839. package/agent/providers/base.py +22 -3
  840. package/agent/pyproject.toml +115 -21
  841. package/agent/run_agent.py +733 -39
  842. package/agent/scripts/build_skills_index.py +51 -19
  843. package/agent/scripts/check_subprocess_stdin.py +177 -0
  844. package/agent/scripts/contributor_audit.py +2 -0
  845. package/agent/scripts/docker_config_migrate.py +67 -0
  846. package/agent/scripts/install.cmd +3 -3
  847. package/agent/scripts/install.ps1 +580 -154
  848. package/agent/scripts/install.sh +402 -185
  849. package/agent/scripts/lib/node-bootstrap.sh +39 -4
  850. package/agent/scripts/release.py +183 -0
  851. package/agent/scripts/run_tests.sh +1 -0
  852. package/agent/scripts/run_tests_parallel.py +18 -23
  853. package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
  854. package/agent/setup.py +59 -0
  855. package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
  856. package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
  857. package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
  858. package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
  859. package/agent/skills/clawpump/SKILL.md +53 -5
  860. package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
  861. package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
  862. package/agent/skills/github/github-auth/SKILL.md +2 -2
  863. package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
  864. package/agent/skills/github/github-code-review/SKILL.md +2 -2
  865. package/agent/skills/github/github-issues/SKILL.md +2 -2
  866. package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
  867. package/agent/skills/github/github-repo-management/SKILL.md +2 -2
  868. package/agent/skills/media/gif-search/SKILL.md +1 -1
  869. package/agent/skills/media/youtube-content/SKILL.md +10 -7
  870. package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
  871. package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
  872. package/agent/skills/productivity/airtable/SKILL.md +2 -2
  873. package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
  874. package/agent/skills/productivity/notion/SKILL.md +2 -2
  875. package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
  876. package/agent/skills/research/llm-wiki/SKILL.md +1 -1
  877. package/agent/skills/social-media/xurl/SKILL.md +9 -0
  878. package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
  879. package/agent/skills/software-development/plan/SKILL.md +285 -5
  880. package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
  881. package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
  882. package/agent/skills/software-development/spike/SKILL.md +2 -2
  883. package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
  884. package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
  885. package/agent/tools/approval.py +302 -4
  886. package/agent/tools/async_delegation.py +386 -0
  887. package/agent/tools/blueprints.py +325 -0
  888. package/agent/tools/browser_cdp_tool.py +3 -3
  889. package/agent/tools/browser_tool.py +34 -6
  890. package/agent/tools/checkpoint_manager.py +31 -1
  891. package/agent/tools/clarify_tool.py +55 -5
  892. package/agent/tools/code_execution_tool.py +31 -14
  893. package/agent/tools/computer_use/cua_backend.py +81 -3
  894. package/agent/tools/computer_use/tool.py +79 -5
  895. package/agent/tools/computer_use/vision_routing.py +55 -3
  896. package/agent/tools/credential_files.py +31 -12
  897. package/agent/tools/cronjob_tools.py +30 -20
  898. package/agent/tools/delegate_tool.py +356 -31
  899. package/agent/tools/env_probe.py +1 -0
  900. package/agent/tools/environments/docker.py +163 -8
  901. package/agent/tools/environments/file_sync.py +2 -1
  902. package/agent/tools/environments/local.py +74 -23
  903. package/agent/tools/environments/singularity.py +4 -1
  904. package/agent/tools/environments/ssh.py +78 -11
  905. package/agent/tools/file_operations.py +277 -41
  906. package/agent/tools/file_tools.py +166 -28
  907. package/agent/tools/image_generation_tool.py +515 -29
  908. package/agent/tools/kanban_tools.py +99 -0
  909. package/agent/tools/lazy_deps.py +33 -2
  910. package/agent/tools/mcp_oauth.py +5 -5
  911. package/agent/tools/mcp_oauth_manager.py +7 -5
  912. package/agent/tools/mcp_tool.py +840 -33
  913. package/agent/tools/memory_tool.py +335 -38
  914. package/agent/tools/osv_check.py +15 -1
  915. package/agent/tools/process_registry.py +155 -11
  916. package/agent/tools/read_extract.py +248 -0
  917. package/agent/tools/read_terminal_tool.py +93 -0
  918. package/agent/tools/schema_sanitizer.py +38 -0
  919. package/agent/tools/send_message_tool.py +163 -49
  920. package/agent/tools/session_search_tool.py +189 -7
  921. package/agent/tools/skill_manager_tool.py +202 -3
  922. package/agent/tools/skill_usage.py +52 -4
  923. package/agent/tools/skills_hub.py +184 -44
  924. package/agent/tools/skills_sync.py +232 -5
  925. package/agent/tools/skills_tool.py +125 -11
  926. package/agent/tools/terminal_tool.py +148 -26
  927. package/agent/tools/tirith_security.py +2 -0
  928. package/agent/tools/todo_tool.py +32 -1
  929. package/agent/tools/transcription_tools.py +13 -5
  930. package/agent/tools/tts_tool.py +332 -38
  931. package/agent/tools/url_safety.py +52 -1
  932. package/agent/tools/vision_tools.py +124 -39
  933. package/agent/tools/voice_mode.py +4 -3
  934. package/agent/tools/web_tools.py +45 -15
  935. package/agent/tools/write_approval.py +493 -0
  936. package/agent/toolsets.py +34 -10
  937. package/agent/trajectory_compressor.py +81 -10
  938. package/agent/tui_gateway/entry.py +43 -6
  939. package/agent/tui_gateway/server.py +3335 -330
  940. package/agent/tui_gateway/slash_worker.py +61 -0
  941. package/agent/tui_gateway/ws.py +67 -9
  942. package/agent/ui-tui/eslint.config.mjs +0 -4
  943. package/agent/ui-tui/package.json +6 -6
  944. package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
  945. package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
  946. package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
  947. package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
  948. package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
  949. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
  950. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
  951. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
  952. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
  953. package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
  954. package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
  955. package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
  956. package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
  957. package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
  958. package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
  959. package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
  960. package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
  961. package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
  962. package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
  963. package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
  964. package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
  965. package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
  966. package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
  967. package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
  968. package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
  969. package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
  970. package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
  971. package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
  972. package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
  973. package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
  974. package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
  975. package/agent/ui-tui/src/app/interfaces.ts +64 -1
  976. package/agent/ui-tui/src/app/overlayStore.ts +18 -2
  977. package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
  978. package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
  979. package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
  980. package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
  981. package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
  982. package/agent/ui-tui/src/app/slash/registry.ts +4 -0
  983. package/agent/ui-tui/src/app/turnController.ts +145 -2
  984. package/agent/ui-tui/src/app/uiStore.ts +2 -0
  985. package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
  986. package/agent/ui-tui/src/app/useMainApp.ts +54 -8
  987. package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
  988. package/agent/ui-tui/src/app/useSubmission.ts +23 -31
  989. package/agent/ui-tui/src/components/appChrome.tsx +112 -5
  990. package/agent/ui-tui/src/components/appLayout.tsx +9 -0
  991. package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
  992. package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
  993. package/agent/ui-tui/src/components/branding.tsx +15 -3
  994. package/agent/ui-tui/src/components/messageLine.tsx +25 -3
  995. package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
  996. package/agent/ui-tui/src/components/prompts.tsx +31 -17
  997. package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
  998. package/agent/ui-tui/src/components/textInput.tsx +16 -0
  999. package/agent/ui-tui/src/config/env.ts +12 -0
  1000. package/agent/ui-tui/src/config/limits.ts +13 -0
  1001. package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
  1002. package/agent/ui-tui/src/domain/paths.ts +24 -0
  1003. package/agent/ui-tui/src/domain/slash.ts +40 -0
  1004. package/agent/ui-tui/src/entry.tsx +35 -4
  1005. package/agent/ui-tui/src/gatewayClient.ts +22 -10
  1006. package/agent/ui-tui/src/gatewayTypes.ts +130 -1
  1007. package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
  1008. package/agent/ui-tui/src/lib/memory.test.ts +162 -0
  1009. package/agent/ui-tui/src/lib/memory.ts +60 -1
  1010. package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
  1011. package/agent/ui-tui/src/lib/osc52.ts +1 -1
  1012. package/agent/ui-tui/src/lib/text.test.ts +32 -1
  1013. package/agent/ui-tui/src/lib/text.ts +29 -2
  1014. package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
  1015. package/agent/ui-tui/src/types.ts +5 -0
  1016. package/agent/ui-tui/tsconfig.build.json +0 -1
  1017. package/agent/ui-tui/tsconfig.json +2 -1
  1018. package/agent/utils.py +66 -2
  1019. package/agent/uv.lock +308 -696
  1020. package/agent/web/index.html +2 -2
  1021. package/agent/web/package.json +11 -6
  1022. package/agent/web/public/claw-bg.webp +0 -0
  1023. package/agent/web/public/claw-logo.webp +0 -0
  1024. package/agent/web/src/App.tsx +138 -48
  1025. package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
  1026. package/agent/web/src/components/Backdrop.tsx +15 -0
  1027. package/agent/web/src/components/ChatSessionList.tsx +260 -0
  1028. package/agent/web/src/components/ChatSidebar.tsx +262 -78
  1029. package/agent/web/src/components/ConfirmDialog.tsx +122 -0
  1030. package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
  1031. package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
  1032. package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
  1033. package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
  1034. package/agent/web/src/components/ReasoningPicker.tsx +167 -0
  1035. package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
  1036. package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
  1037. package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
  1038. package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
  1039. package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
  1040. package/agent/web/src/contexts/SystemActions.tsx +6 -8
  1041. package/agent/web/src/contexts/profile-context.ts +19 -0
  1042. package/agent/web/src/contexts/useProfileScope.ts +6 -0
  1043. package/agent/web/src/i18n/af.ts +5 -4
  1044. package/agent/web/src/i18n/de.ts +5 -4
  1045. package/agent/web/src/i18n/en.ts +58 -4
  1046. package/agent/web/src/i18n/es.ts +5 -3
  1047. package/agent/web/src/i18n/fr.ts +5 -3
  1048. package/agent/web/src/i18n/ga.ts +5 -4
  1049. package/agent/web/src/i18n/hu.ts +5 -4
  1050. package/agent/web/src/i18n/it.ts +5 -4
  1051. package/agent/web/src/i18n/ja.ts +5 -4
  1052. package/agent/web/src/i18n/ko.ts +5 -4
  1053. package/agent/web/src/i18n/pt.ts +5 -3
  1054. package/agent/web/src/i18n/ru.ts +5 -4
  1055. package/agent/web/src/i18n/tr.ts +5 -4
  1056. package/agent/web/src/i18n/types.ts +59 -1
  1057. package/agent/web/src/i18n/uk.ts +5 -3
  1058. package/agent/web/src/i18n/zh-hant.ts +5 -4
  1059. package/agent/web/src/i18n/zh.ts +5 -4
  1060. package/agent/web/src/index.css +2 -2
  1061. package/agent/web/src/lib/api.ts +819 -52
  1062. package/agent/web/src/lib/dashboard-flags.ts +16 -7
  1063. package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
  1064. package/agent/web/src/lib/reasoning-effort.ts +36 -0
  1065. package/agent/web/src/lib/session-refresh.test.ts +21 -0
  1066. package/agent/web/src/lib/session-refresh.ts +26 -0
  1067. package/agent/web/src/pages/ChannelsPage.tsx +529 -68
  1068. package/agent/web/src/pages/ChatPage.tsx +249 -56
  1069. package/agent/web/src/pages/ConfigPage.tsx +11 -1
  1070. package/agent/web/src/pages/CronPage.tsx +219 -31
  1071. package/agent/web/src/pages/EnvPage.tsx +25 -6
  1072. package/agent/web/src/pages/FilesPage.tsx +525 -0
  1073. package/agent/web/src/pages/McpPage.tsx +80 -3
  1074. package/agent/web/src/pages/ModelsPage.tsx +97 -12
  1075. package/agent/web/src/pages/PluginsPage.tsx +1 -1
  1076. package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
  1077. package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
  1078. package/agent/web/src/pages/SessionsPage.tsx +144 -13
  1079. package/agent/web/src/pages/SkillsPage.tsx +851 -70
  1080. package/agent/web/src/pages/SystemPage.tsx +340 -4
  1081. package/agent/web/src/pages/WalletPage.tsx +401 -0
  1082. package/agent/web/src/pages/WebhooksPage.tsx +145 -15
  1083. package/agent/web/src/pages/X402Page.tsx +207 -0
  1084. package/agent/web/src/plugins/registry.ts +28 -11
  1085. package/agent/web/src/plugins/sdk.d.ts +160 -0
  1086. package/agent/web/src/themes/context.tsx +112 -5
  1087. package/agent/web/src/themes/fonts.ts +167 -0
  1088. package/agent/web/src/themes/index.ts +7 -0
  1089. package/agent/web/tsconfig.app.json +0 -1
  1090. package/agent/web/vite.config.ts +1 -8
  1091. package/agent/web/vitest.config.ts +16 -0
  1092. package/package.json +1 -1
  1093. package/agent/apps/desktop/package-lock.json +0 -18363
  1094. package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
  1095. package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
  1096. package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
  1097. package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
  1098. package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
  1099. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
  1100. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
  1101. package/agent/skills/diagramming/DESCRIPTION.md +0 -3
  1102. package/agent/skills/domain/DESCRIPTION.md +0 -24
  1103. package/agent/skills/gifs/DESCRIPTION.md +0 -3
  1104. package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
  1105. package/agent/skills/mcp/DESCRIPTION.md +0 -3
  1106. package/agent/skills/media/spotify/SKILL.md +0 -135
  1107. package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
  1108. package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
  1109. package/agent/skills/productivity/linear/SKILL.md +0 -380
  1110. package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
  1111. package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
  1112. package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
  1113. package/agent/ui-tui/package-lock.json +0 -7449
  1114. package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
  1115. package/agent/web/package-lock.json +0 -8887
  1116. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
  1117. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
  1118. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
  1119. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
  1120. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
  1121. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
  1122. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
  1123. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
  1124. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
  1125. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
  1126. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
  1127. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
  1128. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
  1129. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
  1130. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
  1131. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
  1132. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
  1133. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
  1134. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
  1135. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
  1136. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
  1137. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
  1138. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
  1139. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
  1140. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
  1141. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
  1142. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
  1143. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
  1144. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
  1145. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
  1146. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
  1147. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
  1148. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
  1149. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
  1150. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
  1151. /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
  1152. /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
  1153. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
  1154. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
  1155. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
  1156. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
  1157. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
  1158. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
  1159. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
  1160. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
  1161. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
  1162. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
  1163. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
  1164. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
  1165. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
  1166. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
  1167. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
  1168. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
  1169. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
  1170. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
  1171. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
  1172. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
  1173. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
  1174. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
  1175. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
  1176. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
  1177. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
  1178. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
  1179. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
  1180. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
  1181. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
  1182. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
  1183. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
  1184. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
  1185. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
  1186. /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
  1187. /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
  1188. /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
  1189. /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
  1190. /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
  1191. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
  1192. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
  1193. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
  1194. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
  1195. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
  1196. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
  1197. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
  1198. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
  1199. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
  1200. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
  1201. /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
  1202. /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
  1203. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
  1204. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
  1205. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
  1206. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
  1207. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
  1208. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
  1209. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
  1210. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
  1211. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
  1212. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
  1213. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
  1214. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/gates-taxonomy.md +0 -0
@@ -10,32 +10,58 @@ Environment variables:
10
10
  MATRIX_USER_ID Full user ID (@bot:server) — required for password login
11
11
  MATRIX_PASSWORD Password (alternative to access token)
12
12
  MATRIX_ENCRYPTION Set "true" to enable E2EE
13
+ MATRIX_E2EE_MODE off | optional | required. Overrides MATRIX_ENCRYPTION
14
+ when set. Legacy MATRIX_ENCRYPTION=true maps to required.
13
15
  MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
14
16
  MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic
15
17
  MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
18
+ MATRIX_ALLOWED_ROOMS Comma-separated Matrix room IDs allowed to trigger turns
16
19
  MATRIX_HOME_ROOM Room ID for cron/notification delivery
17
20
  MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
18
21
  (eyes/checkmark/cross). Default: true
19
22
  MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
20
- MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement (alias of matrix.free_response_rooms)
21
- MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds in these rooms (whitelist, DMs exempt; alias of matrix.allowed_rooms)
23
+ MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
24
+ (alias of matrix.free_response_rooms)
25
+ MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds
26
+ in these rooms (whitelist, DMs exempt; alias of
27
+ matrix.allowed_rooms)
28
+ MATRIX_IGNORE_USER_PATTERNS Comma-separated regular expressions for appservice /
29
+ bridge ghost user IDs to ignore
30
+ MATRIX_PROCESS_NOTICES Set "true" to process inbound m.notice events
31
+ (default: false)
32
+ MATRIX_ALLOW_ROOM_MENTIONS Allow outbound @room mentions to notify whole rooms
33
+ (default: false)
34
+ MATRIX_TOOLS_ALLOW_REDACTION
35
+ Allow Matrix redaction tool execution (default: false)
36
+ MATRIX_TOOLS_ALLOW_INVITES Allow Matrix invite tool execution (default: false)
37
+ MATRIX_TOOLS_ALLOW_ROOM_CREATE
38
+ Allow Matrix room creation tool execution (default: false)
22
39
  MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
23
40
  MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
24
41
  MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
25
42
  MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
43
+ MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false)
44
+ MATRIX_APPROVAL_REQUIRE_SENDER
45
+ Require reaction controls to come from the original requester
46
+ when requester metadata is available (default: true)
47
+ MATRIX_APPROVAL_TIMEOUT_SECONDS
48
+ Reaction approval/model-picker timeout (default: 300)
26
49
  """
27
50
 
28
51
  from __future__ import annotations
29
52
 
30
53
  import asyncio
54
+ import inspect
31
55
  import logging
32
56
  import mimetypes
33
57
  import os
34
58
  import re
35
59
  import time
36
- from dataclasses import dataclass
60
+ from urllib.parse import urlsplit, urlunsplit
61
+ from dataclasses import dataclass, field
37
62
 
38
63
  from html import escape as _html_escape
64
+ from html.parser import HTMLParser
39
65
  from pathlib import Path
40
66
  from typing import Any, Dict, Optional, Set
41
67
 
@@ -107,18 +133,209 @@ from gateway.platforms.helpers import ThreadParticipationTracker
107
133
 
108
134
  logger = logging.getLogger(__name__)
109
135
 
136
+ _MATRIX_BANG_COMMAND_RE = re.compile(
137
+ r"^!([A-Za-z][A-Za-z0-9_-]*)(?=$|\s)(.*)$",
138
+ re.DOTALL,
139
+ )
140
+
141
+
142
+ def _resolve_matrix_bang_command(name: str) -> str | None:
143
+ """Resolve a ``!command`` token to a dispatchable Hermes command token.
144
+
145
+ Matrix clients often reserve leading ``/`` for local client commands.
146
+ Hermes accepts ``!command`` as a Matrix-friendly alias, but only for
147
+ commands that the gateway can actually dispatch so ordinary exclamations
148
+ remain normal chat text.
149
+
150
+ Returns the token form that actually resolves (which may differ from
151
+ *name* only by underscore→hyphen normalization, e.g. ``reload_skills`` →
152
+ ``reload-skills``) so the emitted ``/command`` always resolves downstream,
153
+ or ``None`` when *name* is not a known command. Aliases are intentionally
154
+ left as-is — the gateway dispatcher resolves them to their canonical name.
155
+ """
156
+ if not name:
157
+ return None
158
+ # Try the raw lowercased token first, then its hyphenated variant, so
159
+ # forms like ``!reload_skills`` resolve against ``reload-skills``. We emit
160
+ # whichever candidate resolved (not a forced canonical form) to preserve
161
+ # alias passthrough — the gateway dispatcher canonicalizes aliases itself.
162
+ candidates = [name.lower()]
163
+ hyphenated = name.lower().replace("_", "-")
164
+ if hyphenated != candidates[0]:
165
+ candidates.append(hyphenated)
166
+
167
+ try:
168
+ from hermes_cli.commands import is_gateway_known_command
169
+
170
+ for candidate in candidates:
171
+ if is_gateway_known_command(candidate):
172
+ return candidate
173
+ except Exception:
174
+ logger.debug(
175
+ "Matrix: is_gateway_known_command failed for %r", name, exc_info=True
176
+ )
177
+
178
+ try:
179
+ from agent.skill_commands import get_skill_commands
180
+
181
+ skill_commands = get_skill_commands() or {}
182
+ # Skill command keys are stored slash-prefixed (e.g. "/arxiv"), so
183
+ # compare against the "/candidate" form, not the bare token.
184
+ for candidate in candidates:
185
+ if f"/{candidate}" in skill_commands:
186
+ return candidate
187
+ except Exception:
188
+ logger.debug("Matrix: get_skill_commands failed for %r", name, exc_info=True)
189
+
190
+ return None
191
+
192
+
193
+ def _normalize_matrix_bang_command(text: str) -> str:
194
+ """Convert Matrix ``!command`` aliases to normal Hermes ``/command`` text."""
195
+ if not text or not text.startswith("!"):
196
+ return text
197
+ match = _MATRIX_BANG_COMMAND_RE.match(text)
198
+ if not match:
199
+ return text
200
+ resolved = _resolve_matrix_bang_command(match.group(1))
201
+ if resolved is None:
202
+ return text
203
+ return f"/{resolved}{match.group(2) or ''}"
204
+
205
+
206
+ class _MatrixHtmlSanitizer(HTMLParser):
207
+ """Allowlist sanitizer for Matrix-compatible formatted HTML."""
208
+
209
+ _ALLOWED_TAGS = {
210
+ "a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3",
211
+ "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike",
212
+ "strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
213
+ }
214
+ _VOID_TAGS = {"br", "hr"}
215
+
216
+ def __init__(self) -> None:
217
+ super().__init__(convert_charrefs=False)
218
+ self._parts: list[str] = []
219
+ self._skip_depth = 0
220
+
221
+ @staticmethod
222
+ def _safe_url(value: str) -> str:
223
+ stripped = re.sub(r"[\x00-\x1f\x7f]+", "", value or "").strip()
224
+ match = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", stripped)
225
+ scheme = match.group(1).lower() if match else ""
226
+ if scheme and scheme not in {"http", "https", "matrix", "mailto"}:
227
+ return ""
228
+ return stripped
229
+
230
+ def _safe_attrs(self, tag: str, attrs: list[tuple[str, str | None]]) -> str:
231
+ safe: list[str] = []
232
+ for key, value in attrs:
233
+ attr = str(key or "").lower()
234
+ raw_value = "" if value is None else str(value)
235
+ if attr.startswith("on"):
236
+ continue
237
+ if tag == "a" and attr == "href":
238
+ href = self._safe_url(raw_value)
239
+ if href:
240
+ safe.append(f' href="{_html_escape(href, quote=True)}"')
241
+ elif tag == "code" and attr == "class":
242
+ if re.fullmatch(r"language-[A-Za-z0-9_+.-]{1,64}", raw_value):
243
+ safe.append(f' class="{_html_escape(raw_value, quote=True)}"')
244
+ return "".join(safe)
245
+
246
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
247
+ tag = tag.lower()
248
+ if tag in {"script", "style"}:
249
+ self._skip_depth += 1
250
+ return
251
+ if self._skip_depth:
252
+ return
253
+ if tag not in self._ALLOWED_TAGS:
254
+ return
255
+ if tag in self._VOID_TAGS:
256
+ self._parts.append(f"<{tag}>")
257
+ return
258
+ self._parts.append(f"<{tag}{self._safe_attrs(tag, attrs)}>")
259
+
260
+ def handle_endtag(self, tag: str) -> None:
261
+ tag = tag.lower()
262
+ if tag in {"script", "style"} and self._skip_depth:
263
+ self._skip_depth -= 1
264
+ return
265
+ if self._skip_depth or tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
266
+ return
267
+ self._parts.append(f"</{tag}>")
268
+
269
+ def handle_data(self, data: str) -> None:
270
+ if not self._skip_depth:
271
+ self._parts.append(_html_escape(data))
272
+
273
+ def handle_entityref(self, name: str) -> None:
274
+ if not self._skip_depth:
275
+ self._parts.append(f"&{name};")
276
+
277
+ def handle_charref(self, name: str) -> None:
278
+ if not self._skip_depth:
279
+ self._parts.append(f"&#{name};")
280
+
281
+ def get_html(self) -> str:
282
+ return "".join(self._parts)
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class MatrixRoomIdentity:
287
+ """Resolved Matrix room identity for routing and prompt context."""
288
+
289
+ room_id: str
290
+ room_name: str | None
291
+ room_topic: str | None
292
+ canonical_alias: str | None
293
+ server_name: str | None
294
+ joined_member_count: int | None
295
+ is_direct_account_data: bool
296
+ display_name: str
297
+ has_explicit_name: bool
298
+ chat_type: str
299
+ conflict: bool = False
300
+
110
301
 
111
302
  @dataclass
112
303
  class _MatrixApprovalPrompt:
113
304
  """Tracks a pending Matrix reaction-based exec approval prompt."""
114
305
 
115
- def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
306
+ def __init__(
307
+ self,
308
+ session_key: str,
309
+ chat_id: str,
310
+ message_id: str,
311
+ resolved: bool = False,
312
+ requester_user_id: str | None = None,
313
+ expires_at: float | None = None,
314
+ ):
116
315
  self.session_key = session_key
117
316
  self.chat_id = chat_id
118
317
  self.message_id = message_id
119
318
  self.resolved = resolved
319
+ self.requester_user_id = requester_user_id
320
+ self.expires_at = expires_at
120
321
  self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
121
322
 
323
+
324
+ @dataclass
325
+ class _MatrixModelPickerPrompt:
326
+ """Tracks a pending Matrix reaction-based model picker prompt."""
327
+
328
+ chat_id: str
329
+ message_id: str
330
+ session_key: str
331
+ choices: dict[str, tuple[str, str]]
332
+ on_model_selected: Any
333
+ requester_user_id: str | None = None
334
+ expires_at: float | None = None
335
+ resolved: bool = False
336
+ bot_reaction_events: dict[str, str] = field(default_factory=dict)
337
+
338
+
122
339
  # Matrix message size limit (4000 chars practical, spec has no hard limit
123
340
  # but clients render poorly above this).
124
341
  MAX_MESSAGE_LENGTH = 4000
@@ -155,6 +372,40 @@ _MATRIX_IMAGE_FILENAME_EXTS = frozenset({
155
372
  ".avif",
156
373
  })
157
374
 
375
+ _MATRIX_MODEL_PICKER_REACTIONS = (
376
+ "1\ufe0f\u20e3",
377
+ "2\ufe0f\u20e3",
378
+ "3\ufe0f\u20e3",
379
+ "4\ufe0f\u20e3",
380
+ "5\ufe0f\u20e3",
381
+ "6\ufe0f\u20e3",
382
+ "7\ufe0f\u20e3",
383
+ "8\ufe0f\u20e3",
384
+ "9\ufe0f\u20e3",
385
+ "\U0001f51f",
386
+ )
387
+
388
+ _MATRIX_CAPABILITIES: Dict[str, str] = {
389
+ "text": "yes",
390
+ "threads": "yes",
391
+ "reactions": "yes",
392
+ "approvals": "yes",
393
+ "model picker": "yes",
394
+ "thinking panes": "yes",
395
+ "images": "yes",
396
+ "multiple images": "yes",
397
+ "files": "yes",
398
+ "voice/audio": "yes",
399
+ "video": "yes",
400
+ "E2EE": "off / optional / required",
401
+ "diagnostics": "yes",
402
+ }
403
+
404
+
405
+ def get_matrix_capabilities() -> Dict[str, str]:
406
+ """Return Matrix gateway capabilities for docs and release checks."""
407
+ return dict(_MATRIX_CAPABILITIES)
408
+
158
409
 
159
410
  def _looks_like_matrix_image_filename(text: str) -> bool:
160
411
  """Return True when Matrix image body text is probably just a transport filename.
@@ -181,6 +432,26 @@ def _looks_like_matrix_image_filename(text: str) -> bool:
181
432
  return suffix in _MATRIX_IMAGE_FILENAME_EXTS
182
433
 
183
434
 
435
+ def _matrix_event_timestamp_seconds(event: Any) -> float:
436
+ """Return a Matrix event timestamp in seconds, accepting ms or sec values."""
437
+ raw_ts = (
438
+ getattr(event, "timestamp", None)
439
+ or getattr(event, "server_timestamp", None)
440
+ or 0
441
+ )
442
+ if not raw_ts:
443
+ return 0.0
444
+ try:
445
+ ts = float(raw_ts)
446
+ except (TypeError, ValueError):
447
+ return 0.0
448
+ # Matrix origin_server_ts is milliseconds. Some tests/fakes and SDK objects
449
+ # expose seconds; do not turn those into 1970-era timestamps.
450
+ if ts > 10_000_000_000:
451
+ return ts / 1000.0
452
+ return ts
453
+
454
+
184
455
  def _create_matrix_session(proxy_url: str | None):
185
456
  """Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests.
186
457
 
@@ -237,6 +508,159 @@ def _check_e2ee_deps() -> bool:
237
508
  return False
238
509
 
239
510
 
511
+ def _normalize_e2ee_mode(value: Any) -> str:
512
+ """Normalize Matrix E2EE mode to off/optional/required."""
513
+ raw = str(value or "").strip().lower()
514
+ if raw in ("required", "require", "true", "1", "yes", "on"):
515
+ return "required"
516
+ if raw in ("optional", "prefer", "preferred"):
517
+ return "optional"
518
+ return "off"
519
+
520
+
521
+ def _resolve_e2ee_mode(extra: Optional[Dict[str, Any]] = None) -> str:
522
+ """Resolve E2EE mode with MATRIX_ENCRYPTION backwards compatibility."""
523
+ extra = extra or {}
524
+ explicit = extra.get("e2ee_mode") or os.getenv("MATRIX_E2EE_MODE", "")
525
+ if explicit:
526
+ return _normalize_e2ee_mode(explicit)
527
+ legacy_enabled = extra.get(
528
+ "encryption",
529
+ os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"),
530
+ )
531
+ return "required" if legacy_enabled else "off"
532
+
533
+
534
+ def _redact_matrix_value(value: Any) -> str:
535
+ """Return a safe, non-reversible preview for Matrix diagnostics."""
536
+ text = str(value or "").strip()
537
+ if not text:
538
+ return ""
539
+ return "***"
540
+
541
+
542
+ def _write_matrix_recovery_key_output_file(recovery_key: str) -> Optional[Path]:
543
+ """Write a generated Matrix recovery key to an operator-chosen file.
544
+
545
+ The file is created with mode 0600 and never overwritten. Returns the path
546
+ when written, otherwise None.
547
+ """
548
+ output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
549
+ if not output_file:
550
+ return None
551
+ path = Path(output_file).expanduser()
552
+ path.parent.mkdir(parents=True, exist_ok=True)
553
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
554
+ fd = os.open(path, flags, 0o600)
555
+ try:
556
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
557
+ fh.write(recovery_key)
558
+ fh.write("\n")
559
+ except Exception:
560
+ try:
561
+ os.close(fd)
562
+ except OSError:
563
+ pass
564
+ raise
565
+ return path
566
+
567
+
568
+ def _get_matrix_recovery_key_output_target() -> tuple[Optional[Path], str]:
569
+ """Return a usable one-time recovery-key output path, or a redacted reason."""
570
+ output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
571
+ if not output_file:
572
+ return None, "not_configured"
573
+ path = Path(output_file).expanduser()
574
+ if path.exists():
575
+ return None, "exists"
576
+ try:
577
+ path.parent.mkdir(parents=True, exist_ok=True)
578
+ except Exception as exc:
579
+ return None, f"unusable: {exc}"
580
+ return path, ""
581
+
582
+
583
+ def _handle_generated_matrix_recovery_key(mxid: str, recovery_key: str) -> None:
584
+ """Handle a freshly generated Matrix recovery key without logging it."""
585
+ try:
586
+ output_path = _write_matrix_recovery_key_output_file(recovery_key)
587
+ except FileExistsError:
588
+ logger.warning(
589
+ "Matrix: bootstrapped cross-signing for %s. Recovery key output file "
590
+ "already exists; refusing to overwrite. Store the generated key "
591
+ "securely and set MATRIX_RECOVERY_KEY for future restarts.",
592
+ mxid,
593
+ )
594
+ return
595
+ except Exception as exc:
596
+ logger.warning(
597
+ "Matrix: bootstrapped cross-signing for %s, but failed to write "
598
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE: %s. Store the generated key "
599
+ "securely and set MATRIX_RECOVERY_KEY for future restarts.",
600
+ mxid,
601
+ exc,
602
+ )
603
+ return
604
+
605
+ if output_path:
606
+ logger.warning(
607
+ "Matrix: bootstrapped cross-signing for %s. A new recovery key was "
608
+ "written to %s with mode 0600. Move it to your secret store and set "
609
+ "MATRIX_RECOVERY_KEY for future restarts.",
610
+ mxid,
611
+ output_path,
612
+ )
613
+ else:
614
+ logger.warning(
615
+ "Matrix: bootstrapped cross-signing for %s. A new recovery key was "
616
+ "generated but will not be logged. Set MATRIX_RECOVERY_KEY_OUTPUT_FILE "
617
+ "to write it once with mode 0600, or configure MATRIX_RECOVERY_KEY "
618
+ "from your Matrix client before future restarts.",
619
+ mxid,
620
+ )
621
+
622
+
623
+ def _sanitize_matrix_html(html: str) -> str:
624
+ sanitizer = _MatrixHtmlSanitizer()
625
+ try:
626
+ sanitizer.feed(html or "")
627
+ sanitizer.close()
628
+ return sanitizer.get_html()
629
+ except Exception:
630
+ return _html_escape(html or "")
631
+
632
+
633
+ def _redact_url_for_log(url: str) -> str:
634
+ """Strip query/fragment from URLs before logging signed media links."""
635
+ try:
636
+ parts = urlsplit(str(url))
637
+ if not parts.scheme and not parts.netloc:
638
+ return str(url).split("?", 1)[0].split("#", 1)[0]
639
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
640
+ except Exception:
641
+ return "<url>"
642
+
643
+
644
+ def _pre_sanitize_matrix_markdown(text: str) -> str:
645
+ """Remove unsafe raw HTML before Markdown conversion can escape it."""
646
+ result = re.sub(
647
+ r"(?is)<\s*(script|style)\b[^>]*>.*?<\s*/\s*\1\s*>",
648
+ "",
649
+ text or "",
650
+ )
651
+ result = re.sub(
652
+ r"""(?is)\s+on[a-z0-9_-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)""",
653
+ "",
654
+ result,
655
+ )
656
+ result = re.sub(
657
+ r"""(?is)\s+(href|src)\s*=\s*("[^"]*(?:javascript|data|vbscript):[^"]*"|'[^']*(?:javascript|data|vbscript):[^']*'|[^\s>]*(?:javascript|data|vbscript):[^\s>]*)""",
658
+ "",
659
+ result,
660
+ )
661
+ return result
662
+
663
+
240
664
  def check_matrix_requirements() -> bool:
241
665
  """Return True if the Matrix adapter can be used.
242
666
 
@@ -302,21 +726,20 @@ def check_matrix_requirements() -> bool:
302
726
  )
303
727
  return False
304
728
 
305
- # If encryption is requested, verify E2EE deps are available at startup
306
- # rather than silently degrading to plaintext-only at connect time.
307
- encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in {
308
- "true",
309
- "1",
310
- "yes",
311
- }
312
- if encryption_requested and not _check_e2ee_deps():
729
+ e2ee_mode = _resolve_e2ee_mode()
730
+ if e2ee_mode == "required" and not _check_e2ee_deps():
313
731
  logger.error(
314
- "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. "
732
+ "Matrix: E2EE is required but dependencies are missing. %s. "
315
733
  "Without this, encrypted rooms will not work. "
316
- "Set MATRIX_ENCRYPTION=false to disable E2EE.",
734
+ "Set MATRIX_E2EE_MODE=off to disable E2EE.",
317
735
  _E2EE_INSTALL_HINT,
318
736
  )
319
737
  return False
738
+ if e2ee_mode == "optional" and not _check_e2ee_deps():
739
+ logger.warning(
740
+ "Matrix: E2EE optional but dependencies are missing. %s",
741
+ _E2EE_INSTALL_HINT,
742
+ )
320
743
 
321
744
  return True
322
745
 
@@ -351,6 +774,13 @@ class _CryptoStateStore:
351
774
  class MatrixAdapter(BasePlatformAdapter):
352
775
  """Gateway adapter for Matrix (any homeserver)."""
353
776
 
777
+ supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
778
+
779
+ # Matrix clients commonly reserve typed "/" for client-local commands;
780
+ # the adapter accepts "!command" as the alias that always reaches Hermes
781
+ # (see _normalize_matrix_bang_command), so instruction text shows "!".
782
+ typed_command_prefix = "!"
783
+
354
784
  # Threshold for detecting Matrix client-side message splits.
355
785
  # When a chunk is near the ~4000-char practical limit, a continuation
356
786
  # is almost certain.
@@ -369,10 +799,8 @@ class MatrixAdapter(BasePlatformAdapter):
369
799
  self._password: str = config.extra.get("password", "") or os.getenv(
370
800
  "MATRIX_PASSWORD", ""
371
801
  )
372
- self._encryption: bool = config.extra.get(
373
- "encryption",
374
- os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"},
375
- )
802
+ self._e2ee_mode: str = _resolve_e2ee_mode(config.extra)
803
+ self._encryption: bool = self._e2ee_mode != "off"
376
804
  self._device_id: str = config.extra.get("device_id", "") or os.getenv(
377
805
  "MATRIX_DEVICE_ID", ""
378
806
  )
@@ -393,9 +821,19 @@ class MatrixAdapter(BasePlatformAdapter):
393
821
  self._late_grace_drops: int = 0
394
822
  self._late_grace_skew: float = 0.0
395
823
  self._clock_skew_warned: bool = False
824
+ self._last_sync_ts: float = 0.0
396
825
 
397
826
  # Cache: room_id → bool (is DM)
398
827
  self._dm_rooms: Dict[str, bool] = {}
828
+ self._room_identities: Dict[str, MatrixRoomIdentity] = {}
829
+ self._room_identity_cached_at: Dict[str, float] = {}
830
+ try:
831
+ self._room_identity_ttl_seconds = float(
832
+ os.getenv("MATRIX_ROOM_IDENTITY_TTL_SECONDS", "60")
833
+ )
834
+ except ValueError:
835
+ self._room_identity_ttl_seconds = 60.0
836
+ self._room_identity_cache_max = 256
399
837
  # Set of room IDs we've joined
400
838
  self._joined_rooms: Set[str] = set()
401
839
  # Event deduplication (bounded deque keeps newest entries)
@@ -410,10 +848,8 @@ class MatrixAdapter(BasePlatformAdapter):
410
848
  # Thread participation tracking (for require_mention bypass)
411
849
  self._threads = ThreadParticipationTracker("matrix")
412
850
 
413
- # Mention/thread gating — parsed once from env vars.
414
- self._require_mention: bool = os.getenv(
415
- "MATRIX_REQUIRE_MENTION", "true"
416
- ).lower() not in {"false", "0", "no"}
851
+ # Mention/thread gating — parsed once from config.extra or env vars.
852
+ self._require_mention: bool = self._parse_require_mention(config)
417
853
  self._thread_require_mention: bool = self._parse_thread_require_mention(config)
418
854
  free_rooms_raw = config.extra.get("free_response_rooms")
419
855
  if free_rooms_raw is None:
@@ -438,17 +874,27 @@ class MatrixAdapter(BasePlatformAdapter):
438
874
  self._allowed_rooms: Set[str] = {
439
875
  r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip()
440
876
  }
441
- self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in {
877
+ self._allow_room_mentions: bool = os.getenv(
878
+ "MATRIX_ALLOW_ROOM_MENTIONS", "false"
879
+ ).lower() in ("true", "1", "yes")
880
+ self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in (
442
881
  "true",
443
882
  "1",
444
883
  "yes",
445
- }
884
+ )
446
885
  self._dm_auto_thread: bool = os.getenv(
447
886
  "MATRIX_DM_AUTO_THREAD", "false"
448
887
  ).lower() in {"true", "1", "yes"}
449
888
  self._dm_mention_threads: bool = os.getenv(
450
889
  "MATRIX_DM_MENTION_THREADS", "false"
451
- ).lower() in {"true", "1", "yes"}
890
+ ).lower() in ("true", "1", "yes")
891
+ raw_session_scope = os.getenv("MATRIX_SESSION_SCOPE", "auto").strip().lower()
892
+ self._matrix_session_scope = (
893
+ raw_session_scope if raw_session_scope in {"auto", "room", "thread"} else "auto"
894
+ )
895
+ self._process_notices: bool = os.getenv(
896
+ "MATRIX_PROCESS_NOTICES", "false"
897
+ ).lower() in ("true", "1", "yes")
452
898
 
453
899
  # Reactions: configurable via MATRIX_REACTIONS (default: true).
454
900
  self._reactions_enabled: bool = os.getenv(
@@ -466,6 +912,10 @@ class MatrixAdapter(BasePlatformAdapter):
466
912
  self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
467
913
  if self._proxy_url:
468
914
  logger.info("Matrix: proxy configured — %s", self._proxy_url)
915
+ try:
916
+ self._max_media_bytes = int(os.getenv("MATRIX_MAX_MEDIA_BYTES", str(100 * 1024 * 1024)))
917
+ except ValueError:
918
+ self._max_media_bytes = 100 * 1024 * 1024
469
919
 
470
920
  # Text batching: merge rapid successive messages (Telegram-style).
471
921
  # Matrix clients split long messages around 4000 chars.
@@ -481,14 +931,41 @@ class MatrixAdapter(BasePlatformAdapter):
481
931
  # Matrix reaction-based dangerous command approvals.
482
932
  self._approval_reaction_map = {
483
933
  "✅": "once",
934
+ "♾️": "always",
935
+ "♾": "always",
936
+ "\u267e\ufe0f": "always",
937
+ "\u267e": "always",
938
+ "❌": "deny",
484
939
  "❎": "deny",
485
940
  }
486
941
  self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
487
942
  self._approval_prompt_by_session: Dict[str, str] = {}
943
+ self._approval_require_sender: bool = os.getenv(
944
+ "MATRIX_APPROVAL_REQUIRE_SENDER", "true"
945
+ ).lower() in ("true", "1", "yes")
946
+ try:
947
+ self._approval_timeout_seconds = int(
948
+ os.getenv("MATRIX_APPROVAL_TIMEOUT_SECONDS", "300")
949
+ )
950
+ except ValueError:
951
+ self._approval_timeout_seconds = 300
952
+ self._model_picker_prompts_by_event: Dict[str, _MatrixModelPickerPrompt] = {}
488
953
  allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
489
954
  self._allowed_user_ids: Set[str] = {
490
955
  u.strip() for u in allowed_users_raw.split(",") if u.strip()
491
956
  }
957
+ self._allowed_room_ids: Set[str] = set(self._allowed_rooms)
958
+ ignore_patterns_raw = os.getenv("MATRIX_IGNORE_USER_PATTERNS", "")
959
+ self._ignored_user_patterns: list[re.Pattern[str]] = []
960
+ for pattern in (p.strip() for p in ignore_patterns_raw.split(",") if p.strip()):
961
+ try:
962
+ self._ignored_user_patterns.append(re.compile(pattern))
963
+ except re.error as exc:
964
+ logger.warning(
965
+ "Matrix: ignoring invalid MATRIX_IGNORE_USER_PATTERNS entry %r: %s",
966
+ pattern,
967
+ exc,
968
+ )
492
969
 
493
970
  def _is_duplicate_event(self, event_id) -> bool:
494
971
  """Return True if this event was already processed. Tracks the ID otherwise."""
@@ -503,6 +980,25 @@ class MatrixAdapter(BasePlatformAdapter):
503
980
  self._processed_events_set.add(event_id)
504
981
  return False
505
982
 
983
+ @staticmethod
984
+ def _parse_require_mention(config) -> bool:
985
+ """Parse require_mention from config.extra or env var.
986
+
987
+ Handles both YAML booleans and string values (``\"true\"``, ``\"false\"``,
988
+ ``\"yes\"``, ``\"no\"``, ``\"on\"``, ``\"off\"``, ``\"1\"``, ``\"0\"``).
989
+ Falls back to ``MATRIX_REQUIRE_MENTION`` env var, default ``true``.
990
+ """
991
+ configured = config.extra.get("require_mention")
992
+ if configured is not None:
993
+ if isinstance(configured, bool):
994
+ return configured
995
+ if isinstance(configured, str):
996
+ return configured.lower() not in {"false", "0", "no", "off"}
997
+ return bool(configured)
998
+ return os.getenv(
999
+ "MATRIX_REQUIRE_MENTION", "true"
1000
+ ).lower() not in {"false", "0", "no", "off"}
1001
+
506
1002
  @staticmethod
507
1003
  def _parse_thread_require_mention(config) -> bool:
508
1004
  """Parse thread_require_mention from config.extra or env var.
@@ -728,173 +1224,180 @@ class MatrixAdapter(BasePlatformAdapter):
728
1224
  # Set up E2EE if requested.
729
1225
  if self._encryption:
730
1226
  if not _check_e2ee_deps():
731
- logger.error(
732
- "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. "
733
- "Refusing to connect encrypted rooms would silently fail.",
734
- _E2EE_INSTALL_HINT,
735
- )
736
- await api.session.close()
737
- return False
738
- try:
739
- from mautrix.crypto import OlmMachine
740
- from mautrix.crypto.store.asyncpg import PgCryptoStore
741
- from mautrix.util.async_db import Database
742
-
743
- _STORE_DIR.mkdir(parents=True, exist_ok=True)
744
-
745
- # Remove legacy pickle file from pre-SQLite era.
746
- legacy_pickle = _STORE_DIR / "crypto_store.pickle"
747
- if legacy_pickle.exists():
748
- logger.info(
749
- "Matrix: removing legacy crypto_store.pickle (migrated to SQLite)"
1227
+ if self._e2ee_mode == "optional":
1228
+ logger.warning(
1229
+ "Matrix: E2EE optional but dependencies are missing. "
1230
+ "Continuing without encrypted-room support. %s",
1231
+ _E2EE_INSTALL_HINT,
1232
+ )
1233
+ self._encryption = False
1234
+ else:
1235
+ logger.error(
1236
+ "Matrix: E2EE is required but dependencies are missing. %s. "
1237
+ "Refusing to connect — encrypted rooms would silently fail.",
1238
+ _E2EE_INSTALL_HINT,
750
1239
  )
751
- legacy_pickle.unlink()
752
-
753
- # Open SQLite-backed crypto store.
754
- crypto_db = Database.create(
755
- f"sqlite:///{_CRYPTO_DB_PATH}",
756
- upgrade_table=PgCryptoStore.upgrade_table,
757
- )
758
- await crypto_db.start()
759
- self._crypto_db = crypto_db
760
-
761
- _acct_id = self._user_id or "hermes"
762
- _pickle_key = f"{_acct_id}:{self._device_id or 'default'}"
763
- crypto_store = PgCryptoStore(
764
- account_id=_acct_id,
765
- pickle_key=_pickle_key,
766
- db=crypto_db,
767
- )
768
- await crypto_store.open()
769
-
770
- # Bind the store to the runtime device_id before any
771
- # put_account() runs. PgCryptoStore defaults _device_id
772
- # to "" and its crypto_account UPSERT never updates the
773
- # device_id column on conflict — so once put_account
774
- # writes blank, it stays blank forever. That breaks
775
- # every downstream device-scoped olm operation: peer
776
- # to-device ciphertext can't find our identity key and
777
- # no megolm sessions ever land. Setting _device_id here
778
- # (in-memory; the on-disk row may not exist yet) makes
779
- # the first put_account write the correct value.
780
- # DeviceID is a NewType(str) so plain str works at runtime.
781
- if client.device_id:
782
- await crypto_store.put_device_id(client.device_id)
783
-
784
- crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
785
- olm = OlmMachine(client, crypto_store, crypto_state)
786
-
787
- # Accept unverified devices so senders share Megolm
788
- # session keys with us automatically.
789
- olm.share_keys_min_trust = TrustState.UNVERIFIED
790
- olm.send_keys_min_trust = TrustState.UNVERIFIED
791
-
792
- await olm.load()
793
-
794
- # Verify our device keys are still on the homeserver.
795
- if not await self._verify_device_keys_on_server(client, olm):
796
- await crypto_db.stop()
797
1240
  await api.session.close()
798
1241
  return False
799
-
800
- # Proactively flush one-time keys to detect stale OTK
801
- # conflicts early. When crypto state is wiped but the
802
- # same device ID is reused, the server may still hold OTKs
803
- # signed with the old ed25519 key. Identity key re-upload
804
- # succeeds but OTK uploads fail ("already exists" with
805
- # mismatched signature). Peers then cannot establish Olm
806
- # sessions and all new messages are undecryptable.
1242
+ if not self._encryption:
1243
+ pass
1244
+ else:
807
1245
  try:
808
- await olm.share_keys()
1246
+ from mautrix.crypto import OlmMachine
1247
+ from mautrix.crypto.store.asyncpg import PgCryptoStore
1248
+ from mautrix.util.async_db import Database
1249
+
1250
+ _STORE_DIR.mkdir(parents=True, exist_ok=True)
809
1251
  except Exception as exc:
810
- exc_str = str(exc)
811
- if "already exists" in exc_str:
1252
+ if self._e2ee_mode == "optional":
1253
+ logger.warning(
1254
+ "Matrix: failed to import optional E2EE client; "
1255
+ "continuing without encrypted-room support: %s. %s",
1256
+ exc,
1257
+ _E2EE_INSTALL_HINT,
1258
+ )
1259
+ self._encryption = False
1260
+ else:
812
1261
  logger.error(
813
- "Matrix: device %s has stale one-time keys on the "
814
- "server signed with a previous identity key. "
815
- "Peers cannot establish new Olm sessions with "
816
- "this device. Delete the device from the "
817
- "homeserver and restart, or generate a new "
818
- "access token to get a fresh device ID.",
819
- client.device_id,
1262
+ "Matrix: failed to import E2EE client: %s. %s",
1263
+ exc,
1264
+ _E2EE_INSTALL_HINT,
820
1265
  )
821
- await crypto_db.stop()
822
1266
  await api.session.close()
823
1267
  return False
824
- # Non-OTK errors are transient (network, etc.) — log
825
- # but allow startup to continue.
826
- logger.warning(
827
- "Matrix: share_keys() warning during startup: %s",
828
- exc,
1268
+ if self._encryption:
1269
+ try:
1270
+ # Remove legacy pickle file from pre-SQLite era.
1271
+ legacy_pickle = _STORE_DIR / "crypto_store.pickle"
1272
+ if legacy_pickle.exists():
1273
+ logger.info(
1274
+ "Matrix: removing legacy crypto_store.pickle (migrated to SQLite)"
1275
+ )
1276
+ legacy_pickle.unlink()
1277
+
1278
+ crypto_db = Database.create(
1279
+ f"sqlite:///{_CRYPTO_DB_PATH}",
1280
+ upgrade_table=PgCryptoStore.upgrade_table,
1281
+ )
1282
+ await crypto_db.start()
1283
+ self._crypto_db = crypto_db
1284
+
1285
+ _acct_id = self._user_id or "hermes"
1286
+ _pickle_key = f"{_acct_id}:{self._device_id or 'default'}"
1287
+ crypto_store = PgCryptoStore(
1288
+ account_id=_acct_id,
1289
+ pickle_key=_pickle_key,
1290
+ db=crypto_db,
829
1291
  )
1292
+ await crypto_store.open()
1293
+
1294
+ if client.device_id:
1295
+ await crypto_store.put_device_id(client.device_id)
1296
+
1297
+ crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
1298
+ olm = OlmMachine(client, crypto_store, crypto_state)
1299
+ olm.share_keys_min_trust = TrustState.UNVERIFIED
1300
+ olm.send_keys_min_trust = TrustState.UNVERIFIED
1301
+
1302
+ await olm.load()
1303
+
1304
+ if not await self._verify_device_keys_on_server(client, olm):
1305
+ await crypto_db.stop()
1306
+ await api.session.close()
1307
+ return False
830
1308
 
831
- # Import cross-signing private keys from SSSS and self-sign
832
- # the current device. Required after any device-key rotation
833
- # (fresh crypto.db, share_keys re-upload) — otherwise the
834
- # device's self-signing signature is stale and peers refuse
835
- # to share Megolm sessions with the rotated device.
836
- recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
837
- if recovery_key:
838
- try:
839
- await olm.verify_with_recovery_key(recovery_key)
840
- logger.info("Matrix: cross-signing verified via recovery key")
841
- except Exception as exc:
842
- logger.warning(
843
- "Matrix: recovery key verification failed: %s", exc
844
- )
845
- else:
846
- # No recovery key — bootstrap cross-signing if the bot
847
- # has none yet. Without this, Element shows "Encrypted
848
- # by a device not verified by its owner" on every
849
- # message from this bot, indefinitely. mautrix's
850
- # generate_recovery_key does the full flow: generates
851
- # MSK/SSK/USK, uploads private keys to SSSS, publishes
852
- # public keys to the homeserver, and signs the current
853
- # device with the new SSK. Some homeservers require UIA
854
- # for /keys/device_signing/upload — those will need an
855
- # alternate path; Continuwuity and Synapse-with-shared-
856
- # secret accept the unauthenticated upload.
857
1309
  try:
858
- own_xsign = await olm.get_own_cross_signing_public_keys()
1310
+ await olm.share_keys()
859
1311
  except Exception as exc:
860
- own_xsign = None
861
- logger.warning(
862
- "Matrix: cross-signing key lookup failed: %s", exc
863
- )
864
- if own_xsign is None:
865
- try:
866
- new_recovery_key = await olm.generate_recovery_key()
867
- logger.warning(
868
- "Matrix: bootstrapped cross-signing for %s. "
869
- "SAVE THIS RECOVERY KEY — set "
870
- "MATRIX_RECOVERY_KEY for future restarts so "
871
- "the bot can re-sign its device after key "
872
- "rotation: %s",
873
- client.mxid,
874
- new_recovery_key,
1312
+ exc_str = str(exc)
1313
+ if "already exists" in exc_str:
1314
+ logger.error(
1315
+ "Matrix: device %s has stale one-time keys on the "
1316
+ "server signed with a previous identity key. "
1317
+ "Delete the device from the homeserver and restart, "
1318
+ "or generate a new access token to get a fresh device ID.",
1319
+ client.device_id,
875
1320
  )
1321
+ await crypto_db.stop()
1322
+ await api.session.close()
1323
+ return False
1324
+ logger.warning("Matrix: share_keys() warning during startup: %s", exc)
1325
+
1326
+ recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
1327
+ if recovery_key:
1328
+ try:
1329
+ await olm.verify_with_recovery_key(recovery_key)
1330
+ logger.info("Matrix: cross-signing verified via recovery key")
876
1331
  except Exception as exc:
877
- logger.warning(
878
- "Matrix: cross-signing bootstrap failed "
879
- "(non-fatal — Element will show 'not "
880
- "verified by its owner'): %s",
881
- exc,
882
- )
1332
+ logger.warning("Matrix: recovery key verification failed: %s", exc)
1333
+ else:
1334
+ try:
1335
+ own_xsign = await olm.get_own_cross_signing_public_keys()
1336
+ except Exception as exc:
1337
+ own_xsign = None
1338
+ logger.warning("Matrix: cross-signing key lookup failed: %s", exc)
1339
+ if own_xsign is None:
1340
+ _, output_error = _get_matrix_recovery_key_output_target()
1341
+ if output_error == "not_configured":
1342
+ logger.warning(
1343
+ "Matrix: cross-signing keys are missing, but "
1344
+ "automatic bootstrap is skipped because "
1345
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not configured. "
1346
+ "Configure MATRIX_RECOVERY_KEY from your Matrix client "
1347
+ "or set MATRIX_RECOVERY_KEY_OUTPUT_FILE to write a new "
1348
+ "recovery key once with mode 0600."
1349
+ )
1350
+ elif output_error == "exists":
1351
+ logger.warning(
1352
+ "Matrix: cross-signing keys are missing, but "
1353
+ "automatic bootstrap is skipped because "
1354
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE already exists and "
1355
+ "will not be overwritten."
1356
+ )
1357
+ elif output_error:
1358
+ logger.warning(
1359
+ "Matrix: cross-signing keys are missing, but "
1360
+ "automatic bootstrap is skipped because "
1361
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not usable: %s",
1362
+ output_error,
1363
+ )
1364
+ else:
1365
+ try:
1366
+ new_recovery_key = await olm.generate_recovery_key()
1367
+ _handle_generated_matrix_recovery_key(
1368
+ str(client.mxid),
1369
+ new_recovery_key,
1370
+ )
1371
+ except Exception as exc:
1372
+ logger.warning(
1373
+ "Matrix: cross-signing bootstrap failed "
1374
+ "(non-fatal — Element will show 'not verified by its owner'): %s",
1375
+ exc,
1376
+ )
883
1377
 
884
- client.crypto = olm
885
- logger.info(
886
- "Matrix: E2EE enabled (store: %s%s)",
887
- str(_CRYPTO_DB_PATH),
888
- f", device_id={client.device_id}" if client.device_id else "",
889
- )
890
- except Exception as exc:
891
- logger.error(
892
- "Matrix: failed to create E2EE client: %s. %s",
893
- exc,
894
- _E2EE_INSTALL_HINT,
895
- )
896
- await api.session.close()
897
- return False
1378
+ client.crypto = olm
1379
+ logger.info(
1380
+ "Matrix: E2EE enabled (store: %s%s)",
1381
+ str(_CRYPTO_DB_PATH),
1382
+ f", device_id={client.device_id}" if client.device_id else "",
1383
+ )
1384
+ except Exception as exc:
1385
+ if self._e2ee_mode == "optional":
1386
+ logger.warning(
1387
+ "Matrix: failed to create optional E2EE client; "
1388
+ "continuing without encrypted-room support: %s. %s",
1389
+ exc,
1390
+ _E2EE_INSTALL_HINT,
1391
+ )
1392
+ self._encryption = False
1393
+ else:
1394
+ logger.error(
1395
+ "Matrix: failed to create E2EE client: %s. %s",
1396
+ exc,
1397
+ _E2EE_INSTALL_HINT,
1398
+ )
1399
+ await api.session.close()
1400
+ return False
898
1401
 
899
1402
  # Register event handlers.
900
1403
  from mautrix.client import InternalEventType as IntEvt
@@ -919,9 +1422,12 @@ class MatrixAdapter(BasePlatformAdapter):
919
1422
  try:
920
1423
  sync_data = await client.sync(timeout=10000, full_state=True)
921
1424
  if isinstance(sync_data, dict):
1425
+ self._last_sync_ts = time.time()
922
1426
  rooms_join = sync_data.get("rooms", {}).get("join", {})
923
1427
  self._joined_rooms.clear()
924
1428
  self._joined_rooms.update(rooms_join.keys())
1429
+ self._room_identities.clear()
1430
+ self._room_identity_cached_at.clear()
925
1431
  # Store the next_batch token so incremental syncs start
926
1432
  # from where the initial sync left off.
927
1433
  nb = sync_data.get("next_batch")
@@ -937,9 +1443,7 @@ class MatrixAdapter(BasePlatformAdapter):
937
1443
  # Dispatch events from the initial sync so the OlmMachine
938
1444
  # receives to-device key shares queued while we were offline.
939
1445
  try:
940
- tasks = client.handle_sync(sync_data)
941
- if tasks:
942
- await asyncio.gather(*tasks)
1446
+ await self._dispatch_sync(sync_data)
943
1447
  except Exception as exc:
944
1448
  logger.warning("Matrix: initial sync event dispatch error: %s", exc)
945
1449
  await self._join_pending_invites(sync_data)
@@ -1017,20 +1521,7 @@ class MatrixAdapter(BasePlatformAdapter):
1017
1521
  for i, chunk in enumerate(chunks):
1018
1522
  msg_content = self._build_text_message_content(chunk)
1019
1523
 
1020
- # Reply-to support.
1021
- if reply_to:
1022
- msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
1023
-
1024
- # Thread support: if metadata has thread_id, send as threaded reply.
1025
- thread_id = (metadata or {}).get("thread_id")
1026
- if thread_id:
1027
- relates_to = msg_content.get("m.relates_to", {})
1028
- relates_to["rel_type"] = "m.thread"
1029
- relates_to["event_id"] = thread_id
1030
- relates_to["is_falling_back"] = True
1031
- if reply_to and "m.in_reply_to" not in relates_to:
1032
- relates_to["m.in_reply_to"] = {"event_id": reply_to}
1033
- msg_content["m.relates_to"] = relates_to
1524
+ self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata)
1034
1525
 
1035
1526
  try:
1036
1527
  event_id = await asyncio.wait_for(
@@ -1077,21 +1568,56 @@ class MatrixAdapter(BasePlatformAdapter):
1077
1568
 
1078
1569
  async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
1079
1570
  """Return room name and type (dm/group)."""
1080
- name = chat_id
1081
- chat_type = "dm" if await self._is_dm_room(chat_id) else "group"
1082
-
1083
- if self._client:
1084
- try:
1085
- name_evt = await self._client.get_state_event(
1086
- RoomID(chat_id),
1087
- EventType.ROOM_NAME,
1088
- )
1089
- if name_evt and hasattr(name_evt, "name") and name_evt.name:
1090
- name = name_evt.name
1091
- except Exception:
1092
- pass
1093
-
1094
- return {"name": name, "type": chat_type}
1571
+ identity = await self._resolve_room_identity(chat_id)
1572
+ chat_type = "dm" if identity.chat_type == "dm" else "group"
1573
+ return {"name": identity.display_name, "type": chat_type}
1574
+
1575
+ def get_diagnostics(self) -> Dict[str, Any]:
1576
+ """Return redacted Matrix readiness/status diagnostics."""
1577
+ now = time.time()
1578
+ token_present = bool(self._access_token)
1579
+ user_id = self._user_id or getattr(self._client, "mxid", "") or ""
1580
+ device_id = self._device_id or getattr(self._client, "device_id", "") or ""
1581
+ return {
1582
+ "platform": "matrix",
1583
+ "homeserver": self._homeserver,
1584
+ "auth": {
1585
+ "access_token_present": token_present,
1586
+ "password_present": bool(self._password),
1587
+ "token_preview": "***" if token_present else "",
1588
+ "user_id": user_id,
1589
+ "device_id_present": bool(device_id),
1590
+ "device_id_preview": _redact_matrix_value(device_id),
1591
+ },
1592
+ "sync": {
1593
+ "connected": self._client is not None,
1594
+ "joined_room_count": len(self._joined_rooms),
1595
+ "last_sync_age_seconds": (
1596
+ max(0.0, now - self._last_sync_ts) if self._last_sync_ts else None
1597
+ ),
1598
+ },
1599
+ "e2ee": {
1600
+ "mode": self._e2ee_mode,
1601
+ "enabled": bool(self._encryption),
1602
+ "deps_available": _check_e2ee_deps(),
1603
+ "crypto_store_path": str(_CRYPTO_DB_PATH),
1604
+ "recovery_key_configured": bool(os.getenv("MATRIX_RECOVERY_KEY", "").strip()),
1605
+ },
1606
+ "policy": {
1607
+ "allowed_user_count": len(self._allowed_user_ids),
1608
+ "allowed_room_count": len(self._allowed_room_ids),
1609
+ "ignored_user_pattern_count": len(self._ignored_user_patterns),
1610
+ "require_mention": self._require_mention,
1611
+ "free_response_room_count": len(self._free_rooms),
1612
+ "allow_room_mentions": self._allow_room_mentions,
1613
+ "process_notices": self._process_notices,
1614
+ "allow_public_rooms": os.getenv("MATRIX_ALLOW_PUBLIC_ROOMS", "").lower()
1615
+ in ("true", "1", "yes"),
1616
+ },
1617
+ "media": {
1618
+ "max_media_bytes": self._max_media_bytes,
1619
+ },
1620
+ }
1095
1621
 
1096
1622
  # ------------------------------------------------------------------
1097
1623
  # Optional overrides
@@ -1166,43 +1692,120 @@ class MatrixAdapter(BasePlatformAdapter):
1166
1692
  )
1167
1693
 
1168
1694
  try:
1169
- # Try aiohttp first (always available), fall back to httpx
1170
- try:
1171
- import aiohttp as _aiohttp
1172
- _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
1173
- async with _aiohttp.ClientSession(**_sess_kw) as http:
1174
- async with http.get(
1175
- image_url,
1176
- timeout=_aiohttp.ClientTimeout(total=30),
1177
- **_req_kw,
1178
- ) as resp:
1179
- resp.raise_for_status()
1180
- data = await resp.read()
1181
- ct = resp.content_type or "image/png"
1182
- fname = (
1183
- image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
1184
- )
1185
- except ImportError:
1186
- import httpx
1187
- _httpx_kw: dict = {}
1188
- if self._proxy_url:
1189
- _httpx_kw["proxy"] = self._proxy_url
1190
- async with httpx.AsyncClient(**_httpx_kw) as http:
1191
- resp = await http.get(image_url, follow_redirects=True, timeout=30)
1192
- resp.raise_for_status()
1193
- data = resp.content
1194
- ct = resp.headers.get("content-type", "image/png")
1195
- fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
1695
+ data, ct, fname = await self._download_external_media_with_cap(image_url)
1196
1696
  except Exception as exc:
1197
- logger.warning("Matrix: failed to download image %s: %s", image_url, exc)
1697
+ logger.warning(
1698
+ "Matrix: failed to download image %s: %s",
1699
+ _redact_url_for_log(image_url),
1700
+ exc,
1701
+ )
1702
+ fallback = (
1703
+ "I couldn't download and upload the image to Matrix. "
1704
+ "The source URL was not shown because it may contain private tokens."
1705
+ )
1706
+ if caption:
1707
+ fallback = f"{caption}\n{fallback}"
1198
1708
  return await self.send(
1199
- chat_id, f"{caption or ''}\n{image_url}".strip(), reply_to
1709
+ chat_id,
1710
+ fallback,
1711
+ reply_to,
1200
1712
  )
1201
1713
 
1202
1714
  return await self._upload_and_send(
1203
1715
  chat_id, data, fname, ct, "m.image", caption, reply_to, metadata
1204
1716
  )
1205
1717
 
1718
+ async def _download_external_media_with_cap(self, url: str) -> tuple[bytes, str, str]:
1719
+ """Download external media while enforcing redirect safety and size caps."""
1720
+ from tools.url_safety import is_safe_url
1721
+
1722
+ if not is_safe_url(url):
1723
+ raise ValueError("blocked unsafe media URL")
1724
+
1725
+ def _check_content_length(headers: Any) -> None:
1726
+ raw = None
1727
+ try:
1728
+ raw = headers.get("Content-Length") or headers.get("content-length")
1729
+ except Exception:
1730
+ raw = None
1731
+ if raw is None:
1732
+ return
1733
+ try:
1734
+ size = int(raw)
1735
+ except (TypeError, ValueError):
1736
+ return
1737
+ if size > self._max_media_bytes:
1738
+ raise ValueError(
1739
+ f"media exceeds Matrix limit ({size} > {self._max_media_bytes} bytes)"
1740
+ )
1741
+
1742
+ def _check_image_content_type(content_type: str) -> str:
1743
+ content_type = str(content_type or "").split(";", 1)[0].strip().lower()
1744
+ if not content_type.startswith("image/"):
1745
+ raise ValueError("external media is not an image")
1746
+ return content_type
1747
+
1748
+ def _append_chunk(parts: list[bytes], total: int, chunk: bytes) -> int:
1749
+ total += len(chunk)
1750
+ if total > self._max_media_bytes:
1751
+ raise ValueError(
1752
+ f"media exceeds Matrix limit (> {self._max_media_bytes} bytes)"
1753
+ )
1754
+ parts.append(chunk)
1755
+ return total
1756
+
1757
+ fname = url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
1758
+
1759
+ try:
1760
+ import aiohttp as _aiohttp
1761
+
1762
+ _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
1763
+ async with _aiohttp.ClientSession(**_sess_kw) as http:
1764
+ async with http.get(
1765
+ url,
1766
+ timeout=_aiohttp.ClientTimeout(total=30),
1767
+ allow_redirects=True,
1768
+ **_req_kw,
1769
+ ) as resp:
1770
+ resp.raise_for_status()
1771
+ if not is_safe_url(str(resp.url)):
1772
+ raise ValueError("blocked unsafe redirect URL")
1773
+ _check_content_length(resp.headers)
1774
+ parts: list[bytes] = []
1775
+ total = 0
1776
+ async for chunk in resp.content.iter_chunked(65536):
1777
+ total = _append_chunk(parts, total, bytes(chunk))
1778
+ ct = _check_image_content_type(
1779
+ getattr(resp, "content_type", None)
1780
+ or resp.headers.get("content-type", "application/octet-stream")
1781
+ )
1782
+ return b"".join(parts), ct, fname
1783
+ except ImportError:
1784
+ import httpx
1785
+
1786
+ _httpx_kw: dict = {}
1787
+ if self._proxy_url:
1788
+ _httpx_kw["proxy"] = self._proxy_url
1789
+ async with httpx.AsyncClient(**_httpx_kw) as http:
1790
+ async with http.stream(
1791
+ "GET",
1792
+ url,
1793
+ follow_redirects=True,
1794
+ timeout=30,
1795
+ ) as resp:
1796
+ resp.raise_for_status()
1797
+ if not is_safe_url(str(resp.url)):
1798
+ raise ValueError("blocked unsafe redirect URL")
1799
+ _check_content_length(resp.headers)
1800
+ parts: list[bytes] = []
1801
+ total = 0
1802
+ async for chunk in resp.aiter_bytes():
1803
+ total = _append_chunk(parts, total, bytes(chunk))
1804
+ ct = _check_image_content_type(
1805
+ resp.headers.get("content-type", "application/octet-stream")
1806
+ )
1807
+ return b"".join(parts), ct, fname
1808
+
1206
1809
  async def send_image_file(
1207
1810
  self,
1208
1811
  chat_id: str,
@@ -1216,6 +1819,42 @@ class MatrixAdapter(BasePlatformAdapter):
1216
1819
  chat_id, image_path, "m.image", caption, reply_to, metadata=metadata
1217
1820
  )
1218
1821
 
1822
+ async def send_multiple_images(
1823
+ self,
1824
+ chat_id: str,
1825
+ images: list[tuple[str, str]],
1826
+ metadata: Optional[Dict[str, Any]] = None,
1827
+ human_delay: float = 0.0,
1828
+ ) -> None:
1829
+ """Send multiple Matrix images as one ordered logical batch."""
1830
+ if not images:
1831
+ return
1832
+ from urllib.parse import unquote as _unquote
1833
+
1834
+ total = len(images)
1835
+ for idx, (image_url, alt_text) in enumerate(images, start=1):
1836
+ if human_delay > 0 and idx > 1:
1837
+ await asyncio.sleep(human_delay)
1838
+ caption = alt_text or None
1839
+ if total > 1 and caption:
1840
+ caption = f"{caption} ({idx}/{total})"
1841
+ if image_url.startswith("file://"):
1842
+ result = await self.send_image_file(
1843
+ chat_id=chat_id,
1844
+ image_path=_unquote(image_url[7:]),
1845
+ caption=caption,
1846
+ metadata=metadata,
1847
+ )
1848
+ else:
1849
+ result = await self.send_image(
1850
+ chat_id=chat_id,
1851
+ image_url=image_url,
1852
+ caption=caption,
1853
+ metadata=metadata,
1854
+ )
1855
+ if not result.success:
1856
+ logger.warning("Matrix: failed to send image %d/%d: %s", idx, total, result.error)
1857
+
1219
1858
  async def send_document(
1220
1859
  self,
1221
1860
  chat_id: str,
@@ -1274,16 +1913,17 @@ class MatrixAdapter(BasePlatformAdapter):
1274
1913
  if not self._client:
1275
1914
  return SendResult(success=False, error="Not connected")
1276
1915
 
1916
+ requester_user_id = str((metadata or {}).get("requester_user_id") or "") or None
1277
1917
  cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
1278
1918
  text = (
1279
1919
  "⚠️ **Dangerous command requires approval**\n"
1280
1920
  f"```\n{cmd_preview}\n```\n"
1281
1921
  f"Reason: {description}\n\n"
1282
- "Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
1283
- "`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
1922
+ "Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
1923
+ "`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
1284
1924
  "You can also click the reaction to approve:\n"
1285
- "✅ = /approve\n"
1286
- "❎ = /deny"
1925
+ "✅ = approve\n"
1926
+ "❎ = deny"
1287
1927
  )
1288
1928
 
1289
1929
  result = await self.send(chat_id, text, metadata=metadata)
@@ -1294,6 +1934,8 @@ class MatrixAdapter(BasePlatformAdapter):
1294
1934
  session_key=session_key,
1295
1935
  chat_id=chat_id,
1296
1936
  message_id=result.message_id,
1937
+ requester_user_id=requester_user_id,
1938
+ expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0),
1297
1939
  )
1298
1940
  old_event = self._approval_prompt_by_session.get(session_key)
1299
1941
  if old_event:
@@ -1301,7 +1943,7 @@ class MatrixAdapter(BasePlatformAdapter):
1301
1943
  self._approval_prompts_by_event[result.message_id] = prompt
1302
1944
  self._approval_prompt_by_session[session_key] = result.message_id
1303
1945
 
1304
- for emoji in ("✅", ""):
1946
+ for emoji in ("✅", "♾️", "❌"):
1305
1947
  try:
1306
1948
  reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
1307
1949
  # Save the bot's reaction event_id for later cleanup
@@ -1312,6 +1954,87 @@ class MatrixAdapter(BasePlatformAdapter):
1312
1954
 
1313
1955
  return result
1314
1956
 
1957
+ async def send_model_picker(
1958
+ self,
1959
+ chat_id: str,
1960
+ providers: list,
1961
+ current_model: str,
1962
+ current_provider: str,
1963
+ session_key: str,
1964
+ on_model_selected,
1965
+ metadata: Optional[Dict[str, Any]] = None,
1966
+ ) -> SendResult:
1967
+ """Send a Matrix reaction-based model picker."""
1968
+ if not self._client:
1969
+ return SendResult(success=False, error="Not connected")
1970
+
1971
+ flat_choices: list[tuple[str, str, str, str]] = []
1972
+ for provider in providers or []:
1973
+ provider_slug = str(provider.get("slug") or "")
1974
+ provider_name = str(provider.get("name") or provider_slug)
1975
+ models = provider.get("models") or []
1976
+ for model_id in models:
1977
+ if len(flat_choices) >= len(_MATRIX_MODEL_PICKER_REACTIONS):
1978
+ break
1979
+ flat_choices.append((
1980
+ _MATRIX_MODEL_PICKER_REACTIONS[len(flat_choices)],
1981
+ str(model_id),
1982
+ provider_slug,
1983
+ provider_name,
1984
+ ))
1985
+ if len(flat_choices) >= len(_MATRIX_MODEL_PICKER_REACTIONS):
1986
+ break
1987
+
1988
+ if not flat_choices:
1989
+ return await self.send(
1990
+ chat_id,
1991
+ "No authenticated models are available for this session.",
1992
+ metadata=metadata,
1993
+ )
1994
+
1995
+ try:
1996
+ from hermes_cli.providers import get_label
1997
+ provider_label = get_label(current_provider)
1998
+ except Exception:
1999
+ provider_label = current_provider
2000
+
2001
+ lines = [
2002
+ "⚙ **Model Configuration**",
2003
+ f"Current model: `{current_model or 'unknown'}`",
2004
+ f"Provider: {provider_label or 'unknown'}",
2005
+ "",
2006
+ "React to choose a model:",
2007
+ ]
2008
+ choices: dict[str, tuple[str, str]] = {}
2009
+ for emoji, model_id, provider_slug, provider_name in flat_choices:
2010
+ choices[emoji] = (model_id, provider_slug)
2011
+ lines.append(f"{emoji} `{model_id}` — {provider_name}")
2012
+
2013
+ result = await self.send(chat_id, "\n".join(lines), metadata=metadata)
2014
+ if not result.success or not result.message_id:
2015
+ return result
2016
+
2017
+ prompt = _MatrixModelPickerPrompt(
2018
+ chat_id=chat_id,
2019
+ message_id=result.message_id,
2020
+ session_key=session_key,
2021
+ choices=choices,
2022
+ on_model_selected=on_model_selected,
2023
+ requester_user_id=str((metadata or {}).get("requester_user_id") or "") or None,
2024
+ expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0),
2025
+ )
2026
+ self._model_picker_prompts_by_event[result.message_id] = prompt
2027
+
2028
+ for emoji in choices:
2029
+ try:
2030
+ reaction_event_id = await self._send_reaction(chat_id, result.message_id, emoji)
2031
+ if reaction_event_id:
2032
+ prompt.bot_reaction_events[emoji] = str(reaction_event_id)
2033
+ except Exception as exc:
2034
+ logger.debug("Matrix: failed to add model picker reaction %s: %s", emoji, exc)
2035
+
2036
+ return result
2037
+
1315
2038
  def format_message(self, content: str) -> str:
1316
2039
  """Pass-through — Matrix supports standard Markdown natively."""
1317
2040
  # Strip image markdown; media is uploaded separately.
@@ -1335,6 +2058,11 @@ class MatrixAdapter(BasePlatformAdapter):
1335
2058
  is_voice: bool = False,
1336
2059
  ) -> SendResult:
1337
2060
  """Upload bytes to Matrix and send as a media message."""
2061
+ if len(data) > self._max_media_bytes:
2062
+ return SendResult(
2063
+ success=False,
2064
+ error=f"Media file exceeds Matrix limit ({len(data)} > {self._max_media_bytes} bytes)",
2065
+ )
1338
2066
 
1339
2067
  upload_data = data
1340
2068
  encrypted_file = None
@@ -1385,16 +2113,7 @@ class MatrixAdapter(BasePlatformAdapter):
1385
2113
  if is_voice:
1386
2114
  msg_content["org.matrix.msc3245.voice"] = {}
1387
2115
 
1388
- if reply_to:
1389
- msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
1390
-
1391
- thread_id = (metadata or {}).get("thread_id")
1392
- if thread_id:
1393
- relates_to = msg_content.get("m.relates_to", {})
1394
- relates_to["rel_type"] = "m.thread"
1395
- relates_to["event_id"] = thread_id
1396
- relates_to["is_falling_back"] = True
1397
- msg_content["m.relates_to"] = relates_to
2116
+ self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata)
1398
2117
 
1399
2118
  try:
1400
2119
  event_id = await self._client.send_message_event(
@@ -1423,6 +2142,15 @@ class MatrixAdapter(BasePlatformAdapter):
1423
2142
  return await self.send(
1424
2143
  room_id, f"{caption or ''}\n(file not found: {file_path})", reply_to
1425
2144
  )
2145
+ try:
2146
+ file_size = p.stat().st_size
2147
+ except OSError:
2148
+ file_size = 0
2149
+ if file_size > self._max_media_bytes:
2150
+ return SendResult(
2151
+ success=False,
2152
+ error=f"Media file exceeds Matrix limit ({file_size} > {self._max_media_bytes} bytes)",
2153
+ )
1426
2154
 
1427
2155
  fname = file_name or p.name
1428
2156
  ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
@@ -1467,10 +2195,13 @@ class MatrixAdapter(BasePlatformAdapter):
1467
2195
  return
1468
2196
 
1469
2197
  if isinstance(sync_data, dict):
2198
+ self._last_sync_ts = time.time()
1470
2199
  # Update joined rooms from sync response.
1471
2200
  rooms_join = sync_data.get("rooms", {}).get("join", {})
1472
2201
  if rooms_join:
1473
2202
  self._joined_rooms.update(rooms_join.keys())
2203
+ self._room_identities.clear()
2204
+ self._room_identity_cached_at.clear()
1474
2205
 
1475
2206
  # Advance the sync token so the next request is
1476
2207
  # incremental instead of a full initial sync.
@@ -1482,9 +2213,7 @@ class MatrixAdapter(BasePlatformAdapter):
1482
2213
  # Dispatch events to registered handlers so that
1483
2214
  # _on_room_message / _on_reaction / _on_invite fire.
1484
2215
  try:
1485
- tasks = client.handle_sync(sync_data)
1486
- if tasks:
1487
- await asyncio.gather(*tasks)
2216
+ await self._dispatch_sync(sync_data)
1488
2217
  except Exception as exc:
1489
2218
  logger.warning("Matrix: sync event dispatch error: %s", exc)
1490
2219
  await self._join_pending_invites(sync_data)
@@ -1513,6 +2242,17 @@ class MatrixAdapter(BasePlatformAdapter):
1513
2242
  # Event callbacks
1514
2243
  # ------------------------------------------------------------------
1515
2244
 
2245
+ async def _dispatch_sync(self, sync_data: Dict[str, Any]) -> None:
2246
+ """Dispatch a sync response through the mautrix event machinery."""
2247
+ client = self._client
2248
+ if not client or not hasattr(client, "handle_sync"):
2249
+ return
2250
+ tasks = client.handle_sync(sync_data)
2251
+ if inspect.isawaitable(tasks):
2252
+ tasks = await tasks
2253
+ if tasks:
2254
+ await asyncio.gather(*tasks)
2255
+
1516
2256
  def _is_self_sender(self, sender: str) -> bool:
1517
2257
  """Return True if the sender refers to the bot's own account.
1518
2258
 
@@ -1569,6 +2309,33 @@ class MatrixAdapter(BasePlatformAdapter):
1569
2309
  return True
1570
2310
  return localpart.startswith("_")
1571
2311
 
2312
+ def _matches_ignored_user_pattern(self, sender: str) -> bool:
2313
+ """Return True when sender matches configured Matrix ignore patterns."""
2314
+ return any(pattern.search(sender or "") for pattern in self._ignored_user_patterns)
2315
+
2316
+ def _is_allowed_matrix_room(self, room_id: str) -> bool:
2317
+ """Return True when MATRIX_ALLOWED_ROOMS permits the room."""
2318
+ return not self._allowed_room_ids or room_id in self._allowed_room_ids
2319
+
2320
+ async def _is_allowed_matrix_room_event(self, room_id: str) -> bool:
2321
+ """Return True when a room event may proceed past intake filters.
2322
+
2323
+ MATRIX_ALLOWED_ROOMS constrains shared rooms. Matrix DMs are exempt so
2324
+ personal chats still work when operators use a room allowlist for
2325
+ project rooms.
2326
+ """
2327
+ if self._is_allowed_matrix_room(room_id):
2328
+ return True
2329
+ try:
2330
+ return await self._is_dm_room(room_id)
2331
+ except Exception as exc:
2332
+ logger.debug(
2333
+ "Matrix: could not resolve room identity for allowlist check in %s: %s",
2334
+ room_id,
2335
+ exc,
2336
+ )
2337
+ return False
2338
+
1572
2339
  async def _on_room_message(self, event: Any) -> None:
1573
2340
  """Handle incoming room message events (text, media)."""
1574
2341
  room_id = str(getattr(event, "room_id", ""))
@@ -1600,6 +2367,19 @@ class MatrixAdapter(BasePlatformAdapter):
1600
2367
  room_id,
1601
2368
  )
1602
2369
  return
2370
+ if self._matches_ignored_user_pattern(sender):
2371
+ logger.debug(
2372
+ "Matrix: ignoring sender %s in %s due to configured ignore pattern",
2373
+ sender,
2374
+ room_id,
2375
+ )
2376
+ return
2377
+ if not await self._is_allowed_matrix_room_event(room_id):
2378
+ logger.info(
2379
+ "Matrix: ignoring message from unauthorized room %s",
2380
+ room_id,
2381
+ )
2382
+ return
1603
2383
 
1604
2384
  # Deduplicate by event ID.
1605
2385
  event_id = str(getattr(event, "event_id", ""))
@@ -1607,12 +2387,7 @@ class MatrixAdapter(BasePlatformAdapter):
1607
2387
  return
1608
2388
 
1609
2389
  # Startup grace: ignore old messages from initial sync.
1610
- raw_ts = (
1611
- getattr(event, "timestamp", None)
1612
- or getattr(event, "server_timestamp", None)
1613
- or 0
1614
- )
1615
- event_ts = raw_ts / 1000.0 if raw_ts else 0.0
2390
+ event_ts = _matrix_event_timestamp_seconds(event)
1616
2391
  if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
1617
2392
  # If we are well past startup but events are still being dropped
1618
2393
  # by the grace check, the host clock is probably set ahead of
@@ -1688,7 +2463,7 @@ class MatrixAdapter(BasePlatformAdapter):
1688
2463
 
1689
2464
  # Ignore m.notice to prevent bot-to-bot loops (m.notice is the
1690
2465
  # conventional msgtype for bot responses in the Matrix ecosystem).
1691
- if msgtype == "m.notice":
2466
+ if msgtype == "m.notice" and not self._process_notices:
1692
2467
  return
1693
2468
 
1694
2469
  # Dispatch by msgtype.
@@ -1697,7 +2472,7 @@ class MatrixAdapter(BasePlatformAdapter):
1697
2472
  await self._handle_media_message(
1698
2473
  room_id, sender, event_id, event_ts, source_content, relates_to, msgtype
1699
2474
  )
1700
- elif msgtype == "m.text":
2475
+ elif msgtype in ("m.text", "m.notice"):
1701
2476
  await self._handle_text_message(
1702
2477
  room_id, sender, event_id, event_ts, source_content, relates_to
1703
2478
  )
@@ -1716,6 +2491,7 @@ class MatrixAdapter(BasePlatformAdapter):
1716
2491
  Returns (body, is_dm, chat_type, thread_id, display_name, source)
1717
2492
  or None if the message should be dropped (mention gating).
1718
2493
  """
2494
+ identity = await self._resolve_room_identity(room_id)
1719
2495
  is_dm = await self._is_dm_room(room_id)
1720
2496
  chat_type = "dm" if is_dm else "group"
1721
2497
 
@@ -1747,8 +2523,9 @@ class MatrixAdapter(BasePlatformAdapter):
1747
2523
 
1748
2524
  is_free_room = room_id in self._free_rooms
1749
2525
  in_bot_thread = bool(thread_id and thread_id in self._threads)
2526
+ is_command = body.startswith("/")
1750
2527
  if self._require_mention and not is_free_room and not in_bot_thread:
1751
- if not is_mentioned:
2528
+ if not is_mentioned and not is_command:
1752
2529
  logger.debug(
1753
2530
  "Matrix: ignoring message %s in %s — no @mention "
1754
2531
  "(set MATRIX_REQUIRE_MENTION=false to disable)",
@@ -1781,18 +2558,34 @@ class MatrixAdapter(BasePlatformAdapter):
1781
2558
  if is_mentioned and self._require_mention:
1782
2559
  body = self._strip_mention(body)
1783
2560
 
1784
- # Auto-thread.
1785
- if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
1786
- thread_id = event_id
1787
- self._threads.mark(thread_id)
2561
+ # Auto-thread/session-scope policy. Real Matrix thread roots are
2562
+ # preserved above; synthetic thread roots are policy-driven.
2563
+ if not thread_id:
2564
+ if is_dm:
2565
+ if self._dm_auto_thread:
2566
+ thread_id = event_id
2567
+ self._threads.mark(thread_id)
2568
+ elif self._matrix_session_scope == "room":
2569
+ thread_id = None
2570
+ elif self._matrix_session_scope == "thread":
2571
+ thread_id = event_id
2572
+ self._threads.mark(thread_id)
2573
+ elif self._auto_thread:
2574
+ thread_id = event_id
2575
+ self._threads.mark(thread_id)
1788
2576
 
1789
2577
  display_name = await self._get_display_name(room_id, sender)
1790
2578
  source = self.build_source(
1791
2579
  chat_id=room_id,
2580
+ chat_name=identity.display_name,
1792
2581
  chat_type=chat_type,
1793
2582
  user_id=sender,
1794
2583
  user_name=display_name,
1795
2584
  thread_id=thread_id,
2585
+ chat_topic=identity.room_topic,
2586
+ guild_id=identity.server_name,
2587
+ parent_chat_id=room_id if thread_id else None,
2588
+ message_id=event_id,
1796
2589
  )
1797
2590
 
1798
2591
  if thread_id:
@@ -1815,6 +2608,7 @@ class MatrixAdapter(BasePlatformAdapter):
1815
2608
  body = source_content.get("body", "") or ""
1816
2609
  if not body:
1817
2610
  return
2611
+ body = _normalize_matrix_bang_command(body)
1818
2612
 
1819
2613
  ctx = await self._resolve_message_context(
1820
2614
  room_id,
@@ -1850,8 +2644,13 @@ class MatrixAdapter(BasePlatformAdapter):
1850
2644
  stripped.append(line)
1851
2645
  body = "\n".join(stripped) if stripped else body
1852
2646
 
2647
+ # Re-run bang normalization after reply-fallback stripping so a quoted
2648
+ # reply whose actual content is a bang command (e.g. ``> quoted\n\n!model``)
2649
+ # is treated as a command, matching how ``/command`` is recognized below.
2650
+ body = _normalize_matrix_bang_command(body)
2651
+
1853
2652
  msg_type = MessageType.TEXT
1854
- if body.startswith(("!", "/")):
2653
+ if body.startswith("/"):
1855
2654
  msg_type = MessageType.COMMAND
1856
2655
 
1857
2656
  msg_event = MessageEvent(
@@ -1881,6 +2680,12 @@ class MatrixAdapter(BasePlatformAdapter):
1881
2680
  """Process a media message event (image, audio, video, file)."""
1882
2681
  body = source_content.get("body", "") or ""
1883
2682
  url = source_content.get("url", "")
2683
+ if url and not str(url).startswith("mxc://"):
2684
+ logger.warning(
2685
+ "[Matrix] Rejecting inbound media %s with non-MXC URL",
2686
+ event_id,
2687
+ )
2688
+ return
1884
2689
 
1885
2690
  # Convert mxc:// to HTTP URL for downstream processing.
1886
2691
  http_url = ""
@@ -1892,11 +2697,30 @@ class MatrixAdapter(BasePlatformAdapter):
1892
2697
  if not isinstance(content_info, dict):
1893
2698
  content_info = {}
1894
2699
  event_mimetype = content_info.get("mimetype", "")
2700
+ event_size = content_info.get("size")
2701
+ try:
2702
+ event_size_int = int(event_size) if event_size is not None else 0
2703
+ except (TypeError, ValueError):
2704
+ event_size_int = 0
2705
+ if event_size_int and event_size_int > self._max_media_bytes:
2706
+ logger.warning(
2707
+ "[Matrix] Rejecting oversized inbound media %s (%d > %d bytes)",
2708
+ event_id,
2709
+ event_size_int,
2710
+ self._max_media_bytes,
2711
+ )
2712
+ return
1895
2713
 
1896
2714
  # For encrypted media, the URL may be in file.url.
1897
2715
  file_content = source_content.get("file", {})
1898
2716
  if not url and isinstance(file_content, dict):
1899
2717
  url = file_content.get("url", "") or ""
2718
+ if url and not str(url).startswith("mxc://"):
2719
+ logger.warning(
2720
+ "[Matrix] Rejecting inbound encrypted media %s with non-MXC URL",
2721
+ event_id,
2722
+ )
2723
+ return
1900
2724
  if url and url.startswith("mxc://"):
1901
2725
  http_url = self._mxc_to_http(url)
1902
2726
 
@@ -2067,6 +2891,8 @@ class MatrixAdapter(BasePlatformAdapter):
2067
2891
  try:
2068
2892
  await self._client.join_room(RoomID(room_id))
2069
2893
  self._joined_rooms.add(room_id)
2894
+ self._room_identities.pop(room_id, None)
2895
+ self._room_identity_cached_at.pop(room_id, None)
2070
2896
  logger.info("Matrix: joined %s", room_id)
2071
2897
  await self._refresh_dm_cache()
2072
2898
  return True
@@ -2236,15 +3062,20 @@ class MatrixAdapter(BasePlatformAdapter):
2236
3062
  if prompt and not prompt.resolved:
2237
3063
  if room_id != prompt.chat_id:
2238
3064
  return
2239
- _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
2240
- if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
2241
- logger.info(
2242
- "Matrix: ignoring approval reaction from unauthorized user %s on %s",
2243
- sender, reacts_to,
2244
- )
3065
+ if self._matrix_prompt_expired(prompt):
3066
+ await self._expire_matrix_approval_prompt(room_id, reacts_to, prompt)
3067
+ return
3068
+ if not await self._validate_matrix_prompt_reactor(
3069
+ room_id, reacts_to, sender, prompt, "approval"
3070
+ ):
2245
3071
  return
2246
3072
  choice = self._approval_reaction_map.get(key)
2247
3073
  if not choice:
3074
+ await self._send_invalid_reaction_feedback(
3075
+ room_id,
3076
+ reacts_to,
3077
+ "That reaction is not valid for this approval prompt.",
3078
+ )
2248
3079
  return
2249
3080
  try:
2250
3081
  from tools.approval import resolve_gateway_approval
@@ -2263,17 +3094,157 @@ class MatrixAdapter(BasePlatformAdapter):
2263
3094
  await self._redact_bot_approval_reactions(room_id, prompt)
2264
3095
  except Exception as exc:
2265
3096
  logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
3097
+ return
3098
+
3099
+ model_prompt = self._model_picker_prompts_by_event.get(reacts_to)
3100
+ if model_prompt and not model_prompt.resolved:
3101
+ if room_id != model_prompt.chat_id:
3102
+ return
3103
+ if self._matrix_prompt_expired(model_prompt):
3104
+ await self._expire_matrix_model_picker_prompt(room_id, reacts_to, model_prompt)
3105
+ return
3106
+ if not await self._validate_matrix_prompt_reactor(
3107
+ room_id, reacts_to, sender, model_prompt, "model picker"
3108
+ ):
3109
+ return
3110
+ selection = model_prompt.choices.get(key)
3111
+ if not selection:
3112
+ await self._send_invalid_reaction_feedback(
3113
+ room_id,
3114
+ reacts_to,
3115
+ "That reaction is not one of the available model choices.",
3116
+ )
3117
+ return
3118
+ model_prompt.resolved = True
3119
+ self._model_picker_prompts_by_event.pop(reacts_to, None)
3120
+ model_id, provider_slug = selection
3121
+ try:
3122
+ confirmation = await model_prompt.on_model_selected(
3123
+ room_id, model_id, provider_slug
3124
+ )
3125
+ await self._redact_bot_model_picker_reactions(room_id, model_prompt)
3126
+ if confirmation:
3127
+ await self.send(room_id, confirmation, reply_to=reacts_to)
3128
+ except Exception as exc:
3129
+ logger.error("Failed to switch model from Matrix reaction: %s", exc)
3130
+ await self.send(
3131
+ room_id,
3132
+ f"Failed to switch model: {exc}",
3133
+ reply_to=reacts_to,
3134
+ )
3135
+ return
3136
+
3137
+ def _matrix_prompt_expired(self, prompt: Any) -> bool:
3138
+ expires_at = getattr(prompt, "expires_at", None)
3139
+ return expires_at is not None and time.monotonic() > float(expires_at)
3140
+
3141
+ async def _validate_matrix_prompt_reactor(
3142
+ self,
3143
+ room_id: str,
3144
+ target_event_id: str,
3145
+ sender: str,
3146
+ prompt: Any,
3147
+ prompt_label: str,
3148
+ ) -> bool:
3149
+ allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {
3150
+ "true",
3151
+ "1",
3152
+ "yes",
3153
+ }
3154
+ if not allow_all and not (
3155
+ self._allowed_user_ids and sender in self._allowed_user_ids
3156
+ ):
3157
+ logger.info(
3158
+ "Matrix: ignoring %s reaction from unauthorized user %s on %s",
3159
+ prompt_label, sender, target_event_id,
3160
+ )
3161
+ await self._send_invalid_reaction_feedback(
3162
+ room_id,
3163
+ target_event_id,
3164
+ "Only an authorized Matrix user can use these controls.",
3165
+ )
3166
+ return False
3167
+
3168
+ requester = getattr(prompt, "requester_user_id", None)
3169
+ approval_require_sender = getattr(self, "_approval_require_sender", True)
3170
+ if approval_require_sender and requester and sender != requester:
3171
+ logger.info(
3172
+ "Matrix: ignoring %s reaction from %s; requester is %s",
3173
+ prompt_label, sender, requester,
3174
+ )
3175
+ await self._send_invalid_reaction_feedback(
3176
+ room_id,
3177
+ target_event_id,
3178
+ "Only the user who requested this action can use these controls.",
3179
+ )
3180
+ return False
3181
+ return True
3182
+
3183
+ async def _send_invalid_reaction_feedback(
3184
+ self,
3185
+ room_id: str,
3186
+ target_event_id: str,
3187
+ text: str,
3188
+ ) -> None:
3189
+ try:
3190
+ await self.send(room_id, text, reply_to=target_event_id)
3191
+ except Exception as exc:
3192
+ logger.debug("Matrix: failed to send invalid reaction feedback: %s", exc)
3193
+
3194
+ async def _expire_matrix_approval_prompt(
3195
+ self,
3196
+ room_id: str,
3197
+ target_event_id: str,
3198
+ prompt: "_MatrixApprovalPrompt",
3199
+ ) -> None:
3200
+ prompt.resolved = True
3201
+ self._approval_prompts_by_event.pop(target_event_id, None)
3202
+ self._approval_prompt_by_session.pop(prompt.session_key, None)
3203
+ await self._redact_bot_approval_reactions(room_id, prompt)
3204
+ await self._send_invalid_reaction_feedback(
3205
+ room_id,
3206
+ target_event_id,
3207
+ "This approval prompt has expired. Run the command again if you still want to approve it.",
3208
+ )
3209
+
3210
+ async def _expire_matrix_model_picker_prompt(
3211
+ self,
3212
+ room_id: str,
3213
+ target_event_id: str,
3214
+ prompt: "_MatrixModelPickerPrompt",
3215
+ ) -> None:
3216
+ prompt.resolved = True
3217
+ self._model_picker_prompts_by_event.pop(target_event_id, None)
3218
+ await self._redact_bot_model_picker_reactions(room_id, prompt)
3219
+ await self._send_invalid_reaction_feedback(
3220
+ room_id,
3221
+ target_event_id,
3222
+ "This model picker has expired. Run `/model` again to choose a model.",
3223
+ )
2266
3224
 
2267
3225
  async def _redact_bot_approval_reactions(
2268
3226
  self,
2269
3227
  room_id: str,
2270
3228
  prompt: "_MatrixApprovalPrompt",
2271
3229
  ) -> None:
2272
- """Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
3230
+ """Redact the bot's seeded approval reactions, leaving only the user's reaction."""
2273
3231
  for emoji, evt_id in prompt.bot_reaction_events.items():
2274
3232
  self._schedule_reaction_redaction(room_id, evt_id, "approval resolved")
2275
3233
  logger.debug("Matrix: scheduled bot reaction redaction %s (%s)", emoji, evt_id)
2276
3234
 
3235
+ async def _redact_bot_model_picker_reactions(
3236
+ self,
3237
+ room_id: str,
3238
+ prompt: "_MatrixModelPickerPrompt",
3239
+ ) -> None:
3240
+ """Redact the bot's seeded model picker reactions."""
3241
+ for emoji, evt_id in prompt.bot_reaction_events.items():
3242
+ try:
3243
+ await self.redact_message(room_id, evt_id, "model picker resolved")
3244
+ logger.debug("Matrix: redacted model picker reaction %s (%s)", emoji, evt_id)
3245
+ except Exception as exc:
3246
+ logger.debug("Matrix: failed to redact model picker reaction %s: %s", emoji, exc)
3247
+
2277
3248
  # ------------------------------------------------------------------
2278
3249
  # Text message aggregation (handles Matrix client-side splits)
2279
3250
  # ------------------------------------------------------------------
@@ -2422,6 +3393,13 @@ class MatrixAdapter(BasePlatformAdapter):
2422
3393
  """Create a new Matrix room."""
2423
3394
  if not self._client:
2424
3395
  return None
3396
+ if preset == "public_chat" and os.getenv("MATRIX_ALLOW_PUBLIC_ROOMS", "").lower() not in (
3397
+ "true",
3398
+ "1",
3399
+ "yes",
3400
+ ):
3401
+ logger.warning("Matrix: refusing to create public room without MATRIX_ALLOW_PUBLIC_ROOMS=true")
3402
+ return None
2425
3403
  try:
2426
3404
  preset_enum = {
2427
3405
  "private_chat": RoomCreatePreset.PRIVATE,
@@ -2456,6 +3434,63 @@ class MatrixAdapter(BasePlatformAdapter):
2456
3434
  logger.warning("Matrix: invite error: %s", exc)
2457
3435
  return False
2458
3436
 
3437
+ async def fetch_history(
3438
+ self,
3439
+ room_id: str,
3440
+ limit: int = 20,
3441
+ from_token: str = "",
3442
+ ) -> list[dict[str, Any]]:
3443
+ """Fetch recent Matrix room history using the live client."""
3444
+ if not self._client:
3445
+ return []
3446
+ limit = max(1, min(int(limit or 20), 100))
3447
+ try:
3448
+ direction = getattr(PaginationDirection, "BACKWARD", "b")
3449
+ if hasattr(self._client, "messages"):
3450
+ response = await self._client.messages(
3451
+ RoomID(room_id),
3452
+ from_token=SyncToken(from_token) if from_token else None,
3453
+ direction=direction,
3454
+ limit=limit,
3455
+ )
3456
+ elif hasattr(self._client, "get_messages"):
3457
+ response = await self._client.get_messages(
3458
+ RoomID(room_id),
3459
+ start=SyncToken(from_token) if from_token else None,
3460
+ direction=direction,
3461
+ limit=limit,
3462
+ )
3463
+ else:
3464
+ logger.debug("Matrix: client has no messages/get_messages method")
3465
+ return []
3466
+ chunk = getattr(response, "chunk", None)
3467
+ if chunk is None and isinstance(response, dict):
3468
+ chunk = response.get("chunk")
3469
+ return [self._serialize_history_event(evt) for evt in (chunk or [])]
3470
+ except Exception as exc:
3471
+ logger.warning("Matrix: fetch history error: %s", exc)
3472
+ return []
3473
+
3474
+ def _serialize_history_event(self, event: Any) -> dict[str, Any]:
3475
+ content = getattr(event, "content", None)
3476
+ if content is None and isinstance(event, dict):
3477
+ content = event.get("content", {})
3478
+ if not isinstance(content, dict):
3479
+ content = dict(content) if hasattr(content, "items") else {}
3480
+ return {
3481
+ "event_id": str(
3482
+ getattr(event, "event_id", "")
3483
+ or (event.get("event_id", "") if isinstance(event, dict) else "")
3484
+ ),
3485
+ "sender": str(
3486
+ getattr(event, "sender", "")
3487
+ or (event.get("sender", "") if isinstance(event, dict) else "")
3488
+ ),
3489
+ "timestamp": _matrix_event_timestamp_seconds(event),
3490
+ "msgtype": str(content.get("msgtype", "")),
3491
+ "body": str(content.get("body", "")),
3492
+ }
3493
+
2459
3494
  # ------------------------------------------------------------------
2460
3495
  # Presence
2461
3496
  # ------------------------------------------------------------------
@@ -2515,22 +3550,152 @@ class MatrixAdapter(BasePlatformAdapter):
2515
3550
  # Helpers
2516
3551
  # ------------------------------------------------------------------
2517
3552
 
2518
- async def _is_dm_room(self, room_id: str) -> bool:
2519
- """Check if a room is a DM."""
2520
- if self._dm_rooms.get(room_id, False):
2521
- return True
2522
- # Fallback: check member count via state store.
3553
+ @staticmethod
3554
+ def _state_event_value(event: Any, key: str) -> Optional[str]:
3555
+ """Extract a simple value from a Matrix state event object or dict."""
3556
+ if event is None:
3557
+ return None
3558
+ value = getattr(event, key, None)
3559
+ if value:
3560
+ return str(value)
3561
+ if isinstance(event, dict):
3562
+ if event.get(key):
3563
+ return str(event[key])
3564
+ content = event.get("content")
3565
+ if isinstance(content, dict) and content.get(key):
3566
+ return str(content[key])
3567
+ content = getattr(event, "content", None)
3568
+ if isinstance(content, dict) and content.get(key):
3569
+ return str(content[key])
3570
+ if content is not None and getattr(content, key, None):
3571
+ return str(getattr(content, key))
3572
+ return None
3573
+
3574
+ async def _get_room_member_count(self, room_id: str) -> Optional[int]:
2523
3575
  state_store = (
2524
3576
  getattr(self._client, "state_store", None) if self._client else None
2525
3577
  )
2526
- if state_store:
2527
- try:
2528
- members = await state_store.get_members(room_id)
2529
- if members and len(members) == 2:
2530
- return True
2531
- except Exception:
2532
- pass
2533
- return False
3578
+ if not state_store:
3579
+ return None
3580
+ try:
3581
+ members = await state_store.get_members(room_id)
3582
+ except Exception:
3583
+ return None
3584
+ if members is None:
3585
+ return None
3586
+ try:
3587
+ return len(members)
3588
+ except TypeError:
3589
+ return None
3590
+
3591
+ async def _get_room_name(self, room_id: str) -> Optional[str]:
3592
+ if not self._client or not hasattr(self._client, "get_state_event"):
3593
+ return None
3594
+ try:
3595
+ event = await self._client.get_state_event(
3596
+ RoomID(room_id),
3597
+ "m.room.name",
3598
+ )
3599
+ except Exception:
3600
+ return None
3601
+ value = self._state_event_value(event, "name")
3602
+ return value.strip() if value and value.strip() else None
3603
+
3604
+ async def _get_room_canonical_alias(self, room_id: str) -> Optional[str]:
3605
+ if not self._client or not hasattr(self._client, "get_state_event"):
3606
+ return None
3607
+ try:
3608
+ event = await self._client.get_state_event(
3609
+ RoomID(room_id),
3610
+ "m.room.canonical_alias",
3611
+ )
3612
+ except Exception:
3613
+ return None
3614
+ value = self._state_event_value(event, "alias")
3615
+ return value.strip() if value and value.strip() else None
3616
+
3617
+ async def _get_room_topic(self, room_id: str) -> Optional[str]:
3618
+ if not self._client or not hasattr(self._client, "get_state_event"):
3619
+ return None
3620
+ try:
3621
+ event = await self._client.get_state_event(
3622
+ RoomID(room_id),
3623
+ "m.room.topic",
3624
+ )
3625
+ except Exception:
3626
+ return None
3627
+ value = self._state_event_value(event, "topic")
3628
+ return value.strip() if value and value.strip() else None
3629
+
3630
+ @staticmethod
3631
+ def _room_server_name(room_id: str) -> Optional[str]:
3632
+ if ":" not in room_id:
3633
+ return None
3634
+ server = room_id.rsplit(":", 1)[-1].strip()
3635
+ return server or None
3636
+
3637
+ def _cache_room_identity(self, room_id: str, identity: MatrixRoomIdentity) -> None:
3638
+ if len(self._room_identities) >= self._room_identity_cache_max:
3639
+ oldest = min(
3640
+ self._room_identity_cached_at,
3641
+ key=self._room_identity_cached_at.get,
3642
+ default=None,
3643
+ )
3644
+ if oldest:
3645
+ self._room_identities.pop(oldest, None)
3646
+ self._room_identity_cached_at.pop(oldest, None)
3647
+ self._room_identities[room_id] = identity
3648
+ self._room_identity_cached_at[room_id] = time.monotonic()
3649
+
3650
+ async def _resolve_room_identity(
3651
+ self,
3652
+ room_id: str,
3653
+ *,
3654
+ force_refresh: bool = False,
3655
+ ) -> MatrixRoomIdentity:
3656
+ """Resolve Matrix room identity without member-count DM heuristics.
3657
+
3658
+ Matrix ``m.direct`` account data is the authoritative DM signal, but
3659
+ explicitly named rooms win over stale/conflicting DM account data.
3660
+ """
3661
+ cached = self._room_identities.get(room_id)
3662
+ cached_at = self._room_identity_cached_at.get(room_id, 0.0)
3663
+ cache_fresh = (
3664
+ self._room_identity_ttl_seconds <= 0
3665
+ or time.monotonic() - cached_at <= self._room_identity_ttl_seconds
3666
+ )
3667
+ if cached is not None and cache_fresh and not force_refresh:
3668
+ return cached
3669
+
3670
+ room_name = await self._get_room_name(room_id)
3671
+ room_topic = await self._get_room_topic(room_id)
3672
+ canonical_alias = await self._get_room_canonical_alias(room_id)
3673
+ member_count = await self._get_room_member_count(room_id)
3674
+ has_explicit_name = bool(room_name)
3675
+ is_direct = bool(self._dm_rooms.get(room_id, False))
3676
+ conflict = bool(is_direct and has_explicit_name)
3677
+ chat_type = "dm" if is_direct and not has_explicit_name else "room"
3678
+ display_name = room_name or canonical_alias or room_id
3679
+
3680
+ identity = MatrixRoomIdentity(
3681
+ room_id=room_id,
3682
+ room_name=room_name,
3683
+ room_topic=room_topic,
3684
+ canonical_alias=canonical_alias,
3685
+ server_name=self._room_server_name(room_id),
3686
+ joined_member_count=member_count,
3687
+ is_direct_account_data=is_direct,
3688
+ display_name=display_name,
3689
+ has_explicit_name=has_explicit_name,
3690
+ chat_type=chat_type,
3691
+ conflict=conflict,
3692
+ )
3693
+ self._cache_room_identity(room_id, identity)
3694
+ return identity
3695
+
3696
+ async def _is_dm_room(self, room_id: str) -> bool:
3697
+ """Check if a room is a DM."""
3698
+ return (await self._resolve_room_identity(room_id)).chat_type == "dm"
2534
3699
 
2535
3700
  async def _refresh_dm_cache(self) -> None:
2536
3701
  """Refresh the DM room cache from m.direct account data."""
@@ -2554,9 +3719,11 @@ class MatrixAdapter(BasePlatformAdapter):
2554
3719
  dm_room_ids: Set[str] = set()
2555
3720
  for user_id, rooms in dm_data.items():
2556
3721
  if isinstance(rooms, list):
2557
- dm_room_ids.update(str(r) for r in rooms)
3722
+ dm_room_ids.update(str(r) for r in rooms if isinstance(r, str))
2558
3723
 
2559
3724
  self._dm_rooms = {rid: (rid in dm_room_ids) for rid in self._joined_rooms}
3725
+ self._room_identities.clear()
3726
+ self._room_identity_cached_at.clear()
2560
3727
 
2561
3728
  # ------------------------------------------------------------------
2562
3729
  # Mention detection helpers
@@ -2566,8 +3733,11 @@ class MatrixAdapter(BasePlatformAdapter):
2566
3733
  """Build Matrix text content with HTML and outbound mention metadata."""
2567
3734
  msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
2568
3735
  mention_user_ids = self._extract_outbound_mentions(text)
3736
+ room_mentioned = self._allow_room_mentions and self._has_outbound_room_mention(text)
2569
3737
  if mention_user_ids:
2570
3738
  msg_content["m.mentions"] = {"user_ids": mention_user_ids}
3739
+ if room_mentioned:
3740
+ msg_content.setdefault("m.mentions", {})["room"] = True
2571
3741
 
2572
3742
  html_source = self._inject_outbound_mention_links(text)
2573
3743
  html = self._markdown_to_html(html_source)
@@ -2577,6 +3747,31 @@ class MatrixAdapter(BasePlatformAdapter):
2577
3747
 
2578
3748
  return msg_content
2579
3749
 
3750
+ def _apply_relation_metadata(
3751
+ self,
3752
+ msg_content: Dict[str, Any],
3753
+ *,
3754
+ reply_to: Optional[str] = None,
3755
+ metadata: Optional[Dict[str, Any]] = None,
3756
+ ) -> None:
3757
+ """Apply Matrix reply/thread relation metadata to an outbound payload."""
3758
+ thread_id = str((metadata or {}).get("thread_id") or "")
3759
+ if reply_to:
3760
+ msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
3761
+ if thread_id:
3762
+ relates_to = msg_content.get("m.relates_to", {})
3763
+ relates_to["rel_type"] = "m.thread"
3764
+ relates_to["event_id"] = thread_id
3765
+ relates_to["is_falling_back"] = True
3766
+ # Matrix clients that do not render threads still use reply
3767
+ # fallback. If no explicit reply target is available, fall back
3768
+ # to the thread root.
3769
+ relates_to.setdefault(
3770
+ "m.in_reply_to",
3771
+ {"event_id": reply_to or thread_id},
3772
+ )
3773
+ msg_content["m.relates_to"] = relates_to
3774
+
2580
3775
  def _extract_outbound_mentions(self, text: str) -> list[str]:
2581
3776
  """Return unique Matrix user IDs mentioned in outbound text."""
2582
3777
  protected, _ = self._protect_outbound_mention_regions(text)
@@ -2589,6 +3784,11 @@ class MatrixAdapter(BasePlatformAdapter):
2589
3784
  mentions.append(user_id)
2590
3785
  return mentions
2591
3786
 
3787
+ def _has_outbound_room_mention(self, text: str) -> bool:
3788
+ """Return True when outbound text contains @room outside protected spans."""
3789
+ protected, _ = self._protect_outbound_mention_regions(text)
3790
+ return bool(re.search(r"(?<![\w/])@room(?![\w:.-])", protected))
3791
+
2592
3792
  def _inject_outbound_mention_links(self, text: str) -> str:
2593
3793
  """Wrap outbound Matrix mentions in markdown links outside code spans."""
2594
3794
  if not text:
@@ -2723,12 +3923,13 @@ class MatrixAdapter(BasePlatformAdapter):
2723
3923
  def _markdown_to_html(self, text: str) -> str:
2724
3924
  """Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html).
2725
3925
 
2726
- Uses the ``markdown`` library when available (installed with the
2727
- ``matrix`` extra). Falls back to a comprehensive regex converter
2728
- that handles fenced code blocks, inline code, headers, bold,
2729
- italic, strikethrough, links, blockquotes, lists, and horizontal
2730
- rules — everything the Matrix HTML spec allows.
3926
+ Uses the ``markdown`` library (a core dependency) when available.
3927
+ Falls back to a comprehensive regex converter that handles fenced
3928
+ code blocks, inline code, headers, bold, italic, strikethrough,
3929
+ links, blockquotes, lists, and horizontal rules — everything the
3930
+ Matrix HTML spec allows.
2731
3931
  """
3932
+ text = _pre_sanitize_matrix_markdown(text)
2732
3933
  try:
2733
3934
  import markdown as _md
2734
3935
 
@@ -2743,11 +3944,11 @@ class MatrixAdapter(BasePlatformAdapter):
2743
3944
 
2744
3945
  if html.count("<p>") == 1:
2745
3946
  html = html.replace("<p>", "").replace("</p>", "")
2746
- return html
3947
+ return _sanitize_matrix_html(html)
2747
3948
  except ImportError:
2748
3949
  pass
2749
3950
 
2750
- return self._markdown_to_html_fallback(text)
3951
+ return _sanitize_matrix_html(self._markdown_to_html_fallback(text))
2751
3952
 
2752
3953
  # ------------------------------------------------------------------
2753
3954
  # Regex-based Markdown -> HTML (no extra dependencies)