@clawpump/claw-agent 0.1.5 → 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.
- package/agent/.dockerignore +67 -0
- package/agent/.envrc +1 -1
- package/agent/.gitattributes +8 -0
- package/agent/AGENTS.md +216 -4
- package/agent/CONTRIBUTING.md +46 -8
- package/agent/Dockerfile +78 -35
- package/agent/MANIFEST.in +2 -0
- package/agent/README.md +12 -5
- package/agent/README.ur-pk.md +261 -0
- package/agent/README.zh-CN.md +11 -8
- package/agent/SECURITY.md +5 -4
- package/agent/acp_adapter/provenance.py +127 -0
- package/agent/acp_adapter/server.py +112 -5
- package/agent/acp_adapter/session.py +1 -6
- package/agent/acp_registry/agent.json +2 -2
- package/agent/agent/account_usage.py +313 -1
- package/agent/agent/agent_init.py +140 -37
- package/agent/agent/agent_runtime_helpers.py +342 -83
- package/agent/agent/anthropic_adapter.py +320 -33
- package/agent/agent/auxiliary_client.py +525 -105
- package/agent/agent/background_review.py +157 -19
- package/agent/agent/bedrock_adapter.py +71 -6
- package/agent/agent/billing_view.py +295 -0
- package/agent/agent/chat_completion_helpers.py +229 -4
- package/agent/agent/codex_responses_adapter.py +86 -10
- package/agent/agent/codex_runtime.py +153 -1
- package/agent/agent/coding_context.py +738 -0
- package/agent/agent/context_compressor.py +392 -44
- package/agent/agent/context_references.py +34 -1
- package/agent/agent/conversation_compression.py +159 -22
- package/agent/agent/conversation_loop.py +643 -908
- package/agent/agent/copilot_acp_client.py +4 -11
- package/agent/agent/credential_pool.py +5 -3
- package/agent/agent/credits_tracker.py +794 -0
- package/agent/agent/curator.py +91 -18
- package/agent/agent/curator_backup.py +26 -10
- package/agent/agent/display.py +42 -1
- package/agent/agent/error_classifier.py +52 -3
- package/agent/agent/errors.py +3 -0
- package/agent/agent/file_safety.py +0 -17
- package/agent/agent/gemini_native_adapter.py +31 -1
- package/agent/agent/i18n.py +48 -4
- package/agent/agent/image_gen_provider.py +74 -5
- package/agent/agent/image_routing.py +29 -0
- package/agent/agent/insights.py +8 -17
- package/agent/agent/lsp/install.py +3 -0
- package/agent/agent/memory_manager.py +326 -31
- package/agent/agent/message_content.py +50 -0
- package/agent/agent/model_metadata.py +214 -3
- package/agent/agent/moonshot_schema.py +8 -1
- package/agent/agent/onboarding.py +60 -0
- package/agent/agent/prompt_builder.py +327 -37
- package/agent/agent/redact.py +1 -0
- package/agent/agent/runtime_cwd.py +34 -5
- package/agent/agent/secret_scope.py +205 -0
- package/agent/agent/secret_sources/bitwarden.py +34 -2
- package/agent/agent/skill_commands.py +90 -1
- package/agent/agent/skill_preprocessing.py +1 -0
- package/agent/agent/skill_utils.py +209 -36
- package/agent/agent/ssl_guard.py +94 -0
- package/agent/agent/system_prompt.py +133 -5
- package/agent/agent/tool_executor.py +496 -70
- package/agent/agent/transports/anthropic.py +83 -21
- package/agent/agent/transports/chat_completions.py +94 -5
- package/agent/agent/transports/codex.py +67 -2
- package/agent/agent/transports/codex_app_server.py +1 -0
- package/agent/agent/transports/codex_app_server_session.py +30 -0
- package/agent/agent/transports/types.py +12 -0
- package/agent/agent/turn_context.py +408 -0
- package/agent/agent/turn_finalizer.py +428 -0
- package/agent/agent/turn_retry_state.py +68 -0
- package/agent/agent/usage_pricing.py +3 -0
- package/agent/apps/bootstrap-installer/package.json +6 -5
- package/agent/apps/bootstrap-installer/src/routes/failure.tsx +12 -5
- package/agent/apps/bootstrap-installer/src/routes/progress.tsx +1 -3
- package/agent/apps/bootstrap-installer/src/store.ts +3 -2
- package/agent/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +172 -7
- package/agent/apps/bootstrap-installer/src-tauri/src/events.rs +14 -1
- package/agent/apps/bootstrap-installer/src-tauri/src/paths.rs +29 -0
- package/agent/apps/bootstrap-installer/src-tauri/src/powershell.rs +93 -3
- package/agent/apps/bootstrap-installer/src-tauri/src/update.rs +695 -39
- package/agent/apps/bootstrap-installer/tsconfig.json +3 -4
- package/agent/apps/desktop/DESIGN.md +167 -0
- package/agent/apps/desktop/README.md +20 -16
- package/agent/apps/desktop/assets/icon.icns +0 -0
- package/agent/apps/desktop/assets/icon.ico +0 -0
- package/agent/apps/desktop/assets/icon.png +0 -0
- package/agent/apps/desktop/electron/backend-env.cjs +112 -0
- package/agent/apps/desktop/electron/backend-env.test.cjs +111 -0
- package/agent/apps/desktop/electron/backend-probes.test.cjs +3 -1
- package/agent/apps/desktop/electron/backend-ready.cjs +66 -0
- package/agent/apps/desktop/electron/bootstrap-platform.cjs +52 -0
- package/agent/apps/desktop/electron/bootstrap-platform.test.cjs +59 -1
- package/agent/apps/desktop/electron/bootstrap-runner.cjs +176 -38
- package/agent/apps/desktop/electron/bootstrap-runner.test.cjs +112 -1
- package/agent/apps/desktop/electron/connection-config.cjs +288 -0
- package/agent/apps/desktop/electron/connection-config.test.cjs +396 -0
- package/agent/apps/desktop/electron/dashboard-token.cjs +99 -0
- package/agent/apps/desktop/electron/dashboard-token.test.cjs +142 -0
- package/agent/apps/desktop/electron/desktop-uninstall.cjs +232 -0
- package/agent/apps/desktop/electron/desktop-uninstall.test.cjs +246 -0
- package/agent/apps/desktop/electron/entitlements.mac.inherit.plist +2 -0
- package/agent/apps/desktop/electron/fs-read-dir.cjs +109 -0
- package/agent/apps/desktop/electron/fs-read-dir.test.cjs +364 -0
- package/agent/apps/desktop/electron/gateway-ws-probe.cjs +188 -0
- package/agent/apps/desktop/electron/gateway-ws-probe.test.cjs +122 -0
- package/agent/apps/desktop/electron/git-root.cjs +54 -0
- package/agent/apps/desktop/electron/git-root.test.cjs +40 -0
- package/agent/apps/desktop/electron/git-worktrees.cjs +174 -0
- package/agent/apps/desktop/electron/hardening.cjs +123 -28
- package/agent/apps/desktop/electron/hardening.test.cjs +163 -0
- package/agent/apps/desktop/electron/main.cjs +3121 -331
- package/agent/apps/desktop/electron/oauth-net-request.cjs +20 -0
- package/agent/apps/desktop/electron/oauth-net-request.test.cjs +34 -0
- package/agent/apps/desktop/electron/preload.cjs +52 -2
- package/agent/apps/desktop/electron/session-windows.cjs +124 -0
- package/agent/apps/desktop/electron/session-windows.test.cjs +199 -0
- package/agent/apps/desktop/electron/update-rebuild.cjs +29 -0
- package/agent/apps/desktop/electron/update-rebuild.test.cjs +55 -0
- package/agent/apps/desktop/electron/update-remote.cjs +56 -0
- package/agent/apps/desktop/electron/update-remote.test.cjs +78 -0
- package/agent/apps/desktop/electron/vscode-marketplace.cjs +331 -0
- package/agent/apps/desktop/electron/vscode-marketplace.test.cjs +113 -0
- package/agent/apps/desktop/electron/windows-child-process.test.cjs +57 -0
- package/agent/apps/desktop/electron/windows-user-env.cjs +76 -0
- package/agent/apps/desktop/electron/windows-user-env.test.cjs +90 -0
- package/agent/apps/desktop/electron/workspace-cwd.cjs +38 -0
- package/agent/apps/desktop/electron/workspace-cwd.test.cjs +45 -0
- package/agent/apps/desktop/eslint.config.mjs +0 -3
- package/agent/apps/desktop/index.html +27 -2
- package/agent/apps/desktop/package.json +31 -11
- package/agent/apps/desktop/pr-assets/session-source-folders.png +0 -0
- package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
- package/agent/apps/desktop/public/nous-girl.jpg +0 -0
- package/agent/apps/desktop/scripts/assert-dist-built.cjs +70 -0
- package/agent/apps/desktop/scripts/assert-dist-built.test.cjs +84 -0
- package/agent/apps/desktop/scripts/before-pack.cjs +78 -0
- package/agent/apps/desktop/scripts/before-pack.test.cjs +53 -0
- package/agent/apps/desktop/scripts/diag-scroll-reset.mjs +229 -0
- package/agent/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +64 -0
- package/agent/apps/desktop/scripts/run-electron-builder.cjs +57 -0
- package/agent/apps/desktop/src/app/agents/index.tsx +53 -45
- package/agent/apps/desktop/src/app/artifacts/index.tsx +102 -83
- package/agent/apps/desktop/src/app/chat/chat-drop-overlay.tsx +29 -8
- package/agent/apps/desktop/src/app/chat/chat-swap-overlay.tsx +47 -0
- package/agent/apps/desktop/src/app/chat/composer/attachments.tsx +81 -45
- package/agent/apps/desktop/src/app/chat/composer/completion-drawer.tsx +13 -24
- package/agent/apps/desktop/src/app/chat/composer/context-menu.tsx +138 -88
- package/agent/apps/desktop/src/app/chat/composer/controls.tsx +138 -90
- package/agent/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
- package/agent/apps/desktop/src/app/chat/composer/focus.ts +32 -0
- package/agent/apps/desktop/src/app/chat/composer/help-hint.tsx +38 -25
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +7 -0
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +22 -12
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +142 -14
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +14 -11
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +9 -6
- package/agent/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
- package/agent/apps/desktop/src/app/chat/composer/index.tsx +930 -180
- package/agent/apps/desktop/src/app/chat/composer/inline-refs.ts +136 -32
- package/agent/apps/desktop/src/app/chat/composer/model-pill.tsx +86 -0
- package/agent/apps/desktop/src/app/chat/composer/queue-panel.tsx +54 -75
- package/agent/apps/desktop/src/app/chat/composer/rich-editor.test.ts +117 -1
- package/agent/apps/desktop/src/app/chat/composer/rich-editor.ts +117 -6
- package/agent/apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
- package/agent/apps/desktop/src/app/chat/composer/status-stack/index.tsx +202 -0
- package/agent/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +155 -0
- package/agent/apps/desktop/src/app/chat/composer/text-utils.test.ts +104 -0
- package/agent/apps/desktop/src/app/chat/composer/text-utils.ts +37 -9
- package/agent/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +50 -0
- package/agent/apps/desktop/src/app/chat/composer/trigger-popover.tsx +105 -40
- package/agent/apps/desktop/src/app/chat/composer/types.ts +5 -0
- package/agent/apps/desktop/src/app/chat/composer/url-dialog.tsx +11 -15
- package/agent/apps/desktop/src/app/chat/composer/voice-activity.tsx +8 -4
- package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
- package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +70 -16
- package/agent/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +52 -16
- package/agent/apps/desktop/src/app/chat/index.tsx +234 -81
- package/agent/apps/desktop/src/app/chat/perf-probe.tsx +69 -21
- package/agent/apps/desktop/src/app/chat/right-rail/preview-console.tsx +44 -40
- package/agent/apps/desktop/src/app/chat/right-rail/preview-file.tsx +71 -25
- package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +40 -1
- package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +55 -53
- package/agent/apps/desktop/src/app/chat/right-rail/preview.tsx +35 -17
- package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
- package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +74 -0
- package/agent/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +356 -0
- package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +1189 -364
- package/agent/apps/desktop/src/app/chat/sidebar/load-more-row.tsx +30 -0
- package/agent/apps/desktop/src/app/chat/sidebar/order.test.ts +21 -0
- package/agent/apps/desktop/src/app/chat/sidebar/order.ts +17 -0
- package/agent/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +524 -0
- package/agent/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +80 -45
- package/agent/apps/desktop/src/app/chat/sidebar/session-row.tsx +120 -25
- package/agent/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +7 -13
- package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
- package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.ts +326 -0
- package/agent/apps/desktop/src/app/chat/thread-loading.ts +7 -2
- package/agent/apps/desktop/src/app/command-center/index.tsx +320 -581
- package/agent/apps/desktop/src/app/command-palette/index.tsx +681 -0
- package/agent/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +157 -0
- package/agent/apps/desktop/src/app/cron/index.tsx +392 -324
- package/agent/apps/desktop/src/app/cron/job-state.ts +29 -0
- package/agent/apps/desktop/src/app/desktop-controller.tsx +618 -123
- package/agent/apps/desktop/src/app/floating-hud.ts +22 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +260 -14
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +48 -4
- package/agent/apps/desktop/src/app/hooks/use-keybinds.ts +270 -0
- package/agent/apps/desktop/src/app/hooks/use-refresh-hotkey.ts +45 -0
- package/agent/apps/desktop/src/app/layout-constants.ts +19 -0
- package/agent/apps/desktop/src/app/messaging/index.tsx +136 -241
- package/agent/apps/desktop/src/app/messaging/platform-icon.tsx +95 -0
- package/agent/apps/desktop/src/app/model-visibility-overlay.tsx +31 -0
- package/agent/apps/desktop/src/app/overlays/overlay-search-input.tsx +18 -62
- package/agent/apps/desktop/src/app/overlays/overlay-split-layout.tsx +59 -7
- package/agent/apps/desktop/src/app/overlays/overlay-view.tsx +9 -5
- package/agent/apps/desktop/src/app/page-search-shell.tsx +42 -20
- package/agent/apps/desktop/src/app/profiles/create-profile-dialog.tsx +165 -0
- package/agent/apps/desktop/src/app/profiles/delete-profile-dialog.tsx +65 -0
- package/agent/apps/desktop/src/app/profiles/index.tsx +174 -199
- package/agent/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +125 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts +27 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/ipc.test.ts +100 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/ipc.ts +12 -18
- package/agent/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +177 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/tree.tsx +35 -21
- package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +75 -3
- package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +152 -5
- package/agent/apps/desktop/src/app/right-sidebar/index.test.tsx +75 -0
- package/agent/apps/desktop/src/app/right-sidebar/index.tsx +166 -129
- package/agent/apps/desktop/src/app/right-sidebar/store.ts +19 -4
- package/agent/apps/desktop/src/app/right-sidebar/terminal/buffer.ts +65 -0
- package/agent/apps/desktop/src/app/right-sidebar/terminal/index.tsx +29 -34
- package/agent/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +18 -6
- package/agent/apps/desktop/src/app/right-sidebar/terminal/selection.ts +93 -32
- package/agent/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +381 -119
- package/agent/apps/desktop/src/app/routes.ts +9 -0
- package/agent/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +17 -7
- package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +365 -47
- package/agent/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +198 -0
- package/agent/apps/desktop/src/app/session/hooks/use-model-controls.ts +70 -34
- package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +1061 -0
- package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +1143 -165
- package/agent/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +341 -2
- package/agent/apps/desktop/src/app/session/hooks/use-route-resume.ts +176 -5
- package/agent/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +259 -0
- package/agent/apps/desktop/src/app/session/hooks/use-session-actions.ts +452 -149
- package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +327 -0
- package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +133 -4
- package/agent/apps/desktop/src/app/session-picker-overlay.tsx +32 -0
- package/agent/apps/desktop/src/app/session-switcher.tsx +107 -0
- package/agent/apps/desktop/src/app/settings/about-settings.tsx +45 -36
- package/agent/apps/desktop/src/app/settings/appearance-settings.tsx +243 -162
- package/agent/apps/desktop/src/app/settings/config-settings.tsx +86 -66
- package/agent/apps/desktop/src/app/settings/constants.ts +459 -122
- package/agent/apps/desktop/src/app/settings/credential-key-ui.tsx +373 -0
- package/agent/apps/desktop/src/app/settings/env-credentials.tsx +198 -0
- package/agent/apps/desktop/src/app/settings/env-var-actions-menu.tsx +136 -0
- package/agent/apps/desktop/src/app/settings/field-copy.ts +56 -0
- package/agent/apps/desktop/src/app/settings/gateway-settings.tsx +385 -72
- package/agent/apps/desktop/src/app/settings/helpers.test.ts +156 -1
- package/agent/apps/desktop/src/app/settings/helpers.ts +30 -2
- package/agent/apps/desktop/src/app/settings/index.tsx +118 -84
- package/agent/apps/desktop/src/app/settings/keys-settings.tsx +62 -419
- package/agent/apps/desktop/src/app/settings/mcp-settings.tsx +65 -60
- package/agent/apps/desktop/src/app/settings/model-settings.test.tsx +129 -5
- package/agent/apps/desktop/src/app/settings/model-settings.tsx +370 -65
- package/agent/apps/desktop/src/app/settings/notifications-settings.tsx +150 -0
- package/agent/apps/desktop/src/app/settings/primitives.tsx +5 -11
- package/agent/apps/desktop/src/app/settings/provider-config-panel.test.tsx +142 -0
- package/agent/apps/desktop/src/app/settings/provider-config-panel.tsx +182 -0
- package/agent/apps/desktop/src/app/settings/providers-settings.test.tsx +171 -0
- package/agent/apps/desktop/src/app/settings/providers-settings.tsx +471 -0
- package/agent/apps/desktop/src/app/settings/sessions-settings.tsx +183 -71
- package/agent/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +135 -1
- package/agent/apps/desktop/src/app/settings/toolset-config-panel.tsx +180 -57
- package/agent/apps/desktop/src/app/settings/types.ts +9 -6
- package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +185 -0
- package/agent/apps/desktop/src/app/settings/use-deep-link-highlight.ts +60 -0
- package/agent/apps/desktop/src/app/shell/app-shell.tsx +59 -13
- package/agent/apps/desktop/src/app/shell/gateway-menu-panel.tsx +37 -32
- package/agent/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +6 -3
- package/agent/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +212 -53
- package/agent/apps/desktop/src/app/shell/keybind-panel.tsx +215 -0
- package/agent/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +84 -0
- package/agent/apps/desktop/src/app/shell/model-edit-submenu.tsx +244 -0
- package/agent/apps/desktop/src/app/shell/model-menu-panel.tsx +392 -0
- package/agent/apps/desktop/src/app/shell/statusbar-controls.tsx +23 -33
- package/agent/apps/desktop/src/app/shell/titlebar-controls.tsx +79 -95
- package/agent/apps/desktop/src/app/shell/titlebar.ts +8 -2
- package/agent/apps/desktop/src/app/skills/index.test.tsx +11 -0
- package/agent/apps/desktop/src/app/skills/index.tsx +79 -64
- package/agent/apps/desktop/src/app/types.ts +85 -0
- package/agent/apps/desktop/src/app/updates-overlay.tsx +110 -105
- package/agent/apps/desktop/src/components/assistant-ui/ansi-text.tsx +34 -0
- package/agent/apps/desktop/src/components/assistant-ui/block-direction.test.tsx +129 -0
- package/agent/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +102 -81
- package/agent/apps/desktop/src/components/assistant-ui/directive-text.tsx +92 -15
- package/agent/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +38 -0
- package/agent/apps/desktop/src/components/assistant-ui/markdown-text.tsx +304 -45
- package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
- package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +48 -0
- package/agent/apps/desktop/src/components/assistant-ui/streaming.test.tsx +142 -90
- package/agent/apps/desktop/src/components/assistant-ui/thread-list.tsx +337 -0
- package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +667 -190
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +299 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +133 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval.tsx +239 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +31 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +152 -134
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +142 -150
- package/agent/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx +14 -12
- package/agent/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
- package/agent/apps/desktop/src/components/assistant-ui/user-message-text.tsx +152 -0
- package/agent/apps/desktop/src/components/boot-failure-overlay.tsx +150 -33
- package/agent/apps/desktop/src/components/boot-failure-reauth.test.ts +100 -0
- package/agent/apps/desktop/src/components/boot-failure-reauth.ts +81 -0
- package/agent/apps/desktop/src/components/brand-mark.tsx +19 -0
- package/agent/apps/desktop/src/components/chat/code-card.tsx +1 -1
- package/agent/apps/desktop/src/components/chat/composer-dock.ts +31 -0
- package/agent/apps/desktop/src/components/chat/diff-lines.tsx +1 -1
- package/agent/apps/desktop/src/components/chat/disclosure-row.tsx +13 -3
- package/agent/apps/desktop/src/components/chat/expandable-block.tsx +52 -0
- package/agent/apps/desktop/src/components/chat/generated-image-result.tsx +174 -0
- package/agent/apps/desktop/src/components/chat/image-generation-placeholder.tsx +70 -37
- package/agent/apps/desktop/src/components/chat/intro.tsx +8 -7
- package/agent/apps/desktop/src/components/chat/preview-attachment.tsx +4 -2
- package/agent/apps/desktop/src/components/chat/shiki-highlighter.test.ts +37 -0
- package/agent/apps/desktop/src/components/chat/shiki-highlighter.tsx +96 -22
- package/agent/apps/desktop/src/components/chat/status-row.tsx +70 -0
- package/agent/apps/desktop/src/components/chat/status-section.tsx +42 -0
- package/agent/apps/desktop/src/components/chat/terminal-output.tsx +54 -0
- package/agent/apps/desktop/src/components/chat/zoomable-image.tsx +70 -109
- package/agent/apps/desktop/src/components/desktop-install-overlay.tsx +154 -84
- package/agent/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +38 -8
- package/agent/apps/desktop/src/components/desktop-onboarding-overlay.tsx +789 -233
- package/agent/apps/desktop/src/components/error-boundary.tsx +77 -0
- package/agent/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +144 -0
- package/agent/apps/desktop/src/components/gateway-connecting-overlay.tsx +7 -1
- package/agent/apps/desktop/src/components/haptics-provider.tsx +24 -0
- package/agent/apps/desktop/src/components/language-switcher.test.tsx +53 -0
- package/agent/apps/desktop/src/components/language-switcher.tsx +175 -0
- package/agent/apps/desktop/src/components/model-picker.tsx +42 -40
- package/agent/apps/desktop/src/components/model-visibility-dialog.tsx +166 -0
- package/agent/apps/desktop/src/components/notifications.tsx +48 -27
- package/agent/apps/desktop/src/components/pane-shell/index.ts +1 -1
- package/agent/apps/desktop/src/components/pane-shell/pane-shell.tsx +146 -9
- package/agent/apps/desktop/src/components/prompt-overlays.tsx +234 -0
- package/agent/apps/desktop/src/components/session-picker.tsx +108 -0
- package/agent/apps/desktop/src/components/ui/action-status.tsx +25 -0
- package/agent/apps/desktop/src/components/ui/badge.tsx +35 -0
- package/agent/apps/desktop/src/components/ui/button.tsx +37 -13
- package/agent/apps/desktop/src/components/ui/confirm-dialog.tsx +109 -0
- package/agent/apps/desktop/src/components/ui/control.ts +25 -0
- package/agent/apps/desktop/src/components/ui/copy-button.test.tsx +36 -0
- package/agent/apps/desktop/src/components/ui/copy-button.tsx +38 -27
- package/agent/apps/desktop/src/components/ui/dialog.tsx +39 -11
- package/agent/apps/desktop/src/components/ui/dropdown-menu.tsx +98 -24
- package/agent/apps/desktop/src/components/ui/error-state.tsx +50 -0
- package/agent/apps/desktop/src/components/ui/fade-text.tsx +9 -2
- package/agent/apps/desktop/src/components/ui/{braille-spinner.tsx → glyph-spinner.tsx} +15 -13
- package/agent/apps/desktop/src/components/ui/input.tsx +5 -2
- package/agent/apps/desktop/src/components/ui/kbd.tsx +83 -12
- package/agent/apps/desktop/src/components/ui/log-view.tsx +19 -0
- package/agent/apps/desktop/src/components/ui/pagination.tsx +12 -5
- package/agent/apps/desktop/src/components/ui/popover.tsx +44 -0
- package/agent/apps/desktop/src/components/ui/search-field.tsx +80 -0
- package/agent/apps/desktop/src/components/ui/segmented-control.tsx +51 -0
- package/agent/apps/desktop/src/components/ui/select.tsx +10 -3
- package/agent/apps/desktop/src/components/ui/sheet.tsx +8 -2
- package/agent/apps/desktop/src/components/ui/sidebar.tsx +18 -25
- package/agent/apps/desktop/src/components/ui/switch.tsx +38 -15
- package/agent/apps/desktop/src/components/ui/textarea.tsx +4 -11
- package/agent/apps/desktop/src/components/ui/tool-icon.tsx +65 -0
- package/agent/apps/desktop/src/components/ui/tooltip.tsx +31 -4
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/agent/apps/desktop/src/global.d.ts +181 -4
- package/agent/apps/desktop/src/hermes.test.ts +60 -0
- package/agent/apps/desktop/src/hermes.ts +190 -13
- package/agent/apps/desktop/src/hooks/use-image-download.ts +85 -0
- package/agent/apps/desktop/src/hooks/use-resize-observer.ts +13 -4
- package/agent/apps/desktop/src/hooks/use-worktree-info.ts +68 -0
- package/agent/apps/desktop/src/i18n/catalog.ts +12 -0
- package/agent/apps/desktop/src/i18n/context.test.tsx +232 -0
- package/agent/apps/desktop/src/i18n/context.tsx +183 -0
- package/agent/apps/desktop/src/i18n/define-locale.ts +41 -0
- package/agent/apps/desktop/src/i18n/en.ts +1921 -0
- package/agent/apps/desktop/src/i18n/index.ts +20 -0
- package/agent/apps/desktop/src/i18n/ja.ts +2053 -0
- package/agent/apps/desktop/src/i18n/languages.test.ts +43 -0
- package/agent/apps/desktop/src/i18n/languages.ts +86 -0
- package/agent/apps/desktop/src/i18n/runtime.test.ts +75 -0
- package/agent/apps/desktop/src/i18n/runtime.ts +53 -0
- package/agent/apps/desktop/src/i18n/types.ts +1559 -0
- package/agent/apps/desktop/src/i18n/zh-hant.ts +1992 -0
- package/agent/apps/desktop/src/i18n/zh.ts +2099 -0
- package/agent/apps/desktop/src/lib/ansi.test.ts +123 -0
- package/agent/apps/desktop/src/lib/ansi.ts +186 -0
- package/agent/apps/desktop/src/lib/chat-messages.test.ts +79 -0
- package/agent/apps/desktop/src/lib/chat-messages.ts +68 -29
- package/agent/apps/desktop/src/lib/chat-runtime.test.ts +65 -1
- package/agent/apps/desktop/src/lib/chat-runtime.ts +39 -3
- package/agent/apps/desktop/src/lib/completion-sound.ts +519 -0
- package/agent/apps/desktop/src/lib/desktop-fs.test.ts +116 -0
- package/agent/apps/desktop/src/lib/desktop-fs.ts +113 -0
- package/agent/apps/desktop/src/lib/desktop-slash-commands.test.ts +89 -6
- package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +270 -131
- package/agent/apps/desktop/src/lib/external-link.test.tsx +27 -0
- package/agent/apps/desktop/src/lib/external-link.tsx +9 -2
- package/agent/apps/desktop/src/lib/gateway-events.test.ts +27 -0
- package/agent/apps/desktop/src/lib/gateway-events.ts +16 -0
- package/agent/apps/desktop/src/lib/gateway-ws-url.test.ts +78 -0
- package/agent/apps/desktop/src/lib/gateway-ws-url.ts +91 -0
- package/agent/apps/desktop/src/lib/generated-images.test.ts +97 -0
- package/agent/apps/desktop/src/lib/generated-images.ts +116 -0
- package/agent/apps/desktop/src/lib/haptics.ts +17 -0
- package/agent/apps/desktop/src/lib/icons.ts +10 -2
- package/agent/apps/desktop/src/lib/keybinds/actions.ts +137 -0
- package/agent/apps/desktop/src/lib/keybinds/combo.test.ts +86 -0
- package/agent/apps/desktop/src/lib/keybinds/combo.ts +195 -0
- package/agent/apps/desktop/src/lib/local-preview.ts +23 -2
- package/agent/apps/desktop/src/lib/markdown-preprocess.ts +20 -7
- package/agent/apps/desktop/src/lib/media.remote.test.ts +90 -0
- package/agent/apps/desktop/src/lib/media.ts +40 -1
- package/agent/apps/desktop/src/lib/model-status-label.test.ts +59 -0
- package/agent/apps/desktop/src/lib/model-status-label.ts +122 -0
- package/agent/apps/desktop/src/lib/mutable-ref.ts +6 -0
- package/agent/apps/desktop/src/lib/profile-color.ts +58 -0
- package/agent/apps/desktop/src/lib/query-client.ts +13 -0
- package/agent/apps/desktop/src/lib/remend-tail.test.ts +105 -0
- package/agent/apps/desktop/src/lib/remend-tail.ts +108 -0
- package/agent/apps/desktop/src/lib/session-export.ts +6 -3
- package/agent/apps/desktop/src/lib/session-ids.test.ts +44 -0
- package/agent/apps/desktop/src/lib/session-ids.ts +26 -0
- package/agent/apps/desktop/src/lib/session-search.test.ts +66 -0
- package/agent/apps/desktop/src/lib/session-search.ts +21 -0
- package/agent/apps/desktop/src/lib/session-source.ts +126 -0
- package/agent/apps/desktop/src/lib/storage.test.ts +25 -0
- package/agent/apps/desktop/src/lib/storage.ts +35 -1
- package/agent/apps/desktop/src/lib/todos.test.ts +46 -1
- package/agent/apps/desktop/src/lib/todos.ts +37 -0
- package/agent/apps/desktop/src/lib/tool-result-summary.ts +5 -1
- package/agent/apps/desktop/src/lib/update-copy.test.ts +38 -0
- package/agent/apps/desktop/src/lib/update-copy.ts +44 -0
- package/agent/apps/desktop/src/lib/use-enter-animation.ts +2 -2
- package/agent/apps/desktop/src/lib/yolo-session.ts +50 -0
- package/agent/apps/desktop/src/main.tsx +19 -19
- package/agent/apps/desktop/src/store/boot.ts +4 -3
- package/agent/apps/desktop/src/store/clarify.test.ts +81 -0
- package/agent/apps/desktop/src/store/clarify.ts +50 -13
- package/agent/apps/desktop/src/store/command-palette.ts +20 -0
- package/agent/apps/desktop/src/store/compaction.test.ts +53 -0
- package/agent/apps/desktop/src/store/compaction.ts +38 -0
- package/agent/apps/desktop/src/store/completion-sound.ts +32 -0
- package/agent/apps/desktop/src/store/composer-input-history.test.ts +147 -0
- package/agent/apps/desktop/src/store/composer-input-history.ts +158 -0
- package/agent/apps/desktop/src/store/composer-queue.test.ts +68 -0
- package/agent/apps/desktop/src/store/composer-queue.ts +76 -0
- package/agent/apps/desktop/src/store/composer-status.test.ts +99 -0
- package/agent/apps/desktop/src/store/composer-status.ts +277 -0
- package/agent/apps/desktop/src/store/composer.test.ts +106 -0
- package/agent/apps/desktop/src/store/composer.ts +116 -0
- package/agent/apps/desktop/src/store/cron.ts +19 -0
- package/agent/apps/desktop/src/store/gateway.ts +280 -6
- package/agent/apps/desktop/src/store/keybinds.ts +143 -0
- package/agent/apps/desktop/src/store/layout.ts +107 -9
- package/agent/apps/desktop/src/store/model-presets.test.ts +51 -0
- package/agent/apps/desktop/src/store/model-presets.ts +86 -0
- package/agent/apps/desktop/src/store/model-visibility.test.ts +99 -0
- package/agent/apps/desktop/src/store/model-visibility.ts +161 -0
- package/agent/apps/desktop/src/store/native-notifications.test.ts +192 -0
- package/agent/apps/desktop/src/store/native-notifications.ts +203 -0
- package/agent/apps/desktop/src/store/notifications.ts +10 -7
- package/agent/apps/desktop/src/store/onboarding.test.ts +271 -1
- package/agent/apps/desktop/src/store/onboarding.ts +268 -38
- package/agent/apps/desktop/src/store/preview.ts +10 -1
- package/agent/apps/desktop/src/store/profile.test.ts +89 -0
- package/agent/apps/desktop/src/store/profile.ts +395 -0
- package/agent/apps/desktop/src/store/prompts.test.ts +127 -0
- package/agent/apps/desktop/src/store/prompts.ts +117 -0
- package/agent/apps/desktop/src/store/session-switcher.test.ts +115 -0
- package/agent/apps/desktop/src/store/session-switcher.ts +128 -0
- package/agent/apps/desktop/src/store/session-sync.ts +25 -0
- package/agent/apps/desktop/src/store/session.test.ts +268 -2
- package/agent/apps/desktop/src/store/session.ts +392 -18
- package/agent/apps/desktop/src/store/subagents.ts +3 -0
- package/agent/apps/desktop/src/store/system-actions.ts +48 -0
- package/agent/apps/desktop/src/store/thread-scroll.ts +58 -5
- package/agent/apps/desktop/src/store/todos.test.ts +47 -0
- package/agent/apps/desktop/src/store/todos.ts +64 -0
- package/agent/apps/desktop/src/store/tool-dismiss.ts +45 -0
- package/agent/apps/desktop/src/store/translucency.ts +38 -0
- package/agent/apps/desktop/src/store/updates.test.ts +187 -2
- package/agent/apps/desktop/src/store/updates.ts +268 -18
- package/agent/apps/desktop/src/store/windows.test.ts +143 -0
- package/agent/apps/desktop/src/store/windows.ts +115 -0
- package/agent/apps/desktop/src/styles.css +510 -119
- package/agent/apps/desktop/src/themes/color.ts +142 -0
- package/agent/apps/desktop/src/themes/context.tsx +128 -75
- package/agent/apps/desktop/src/themes/install.test.ts +119 -0
- package/agent/apps/desktop/src/themes/install.ts +95 -0
- package/agent/apps/desktop/src/themes/presets.test.ts +33 -0
- package/agent/apps/desktop/src/themes/presets.ts +13 -4
- package/agent/apps/desktop/src/themes/profile-theme.test.ts +41 -0
- package/agent/apps/desktop/src/themes/types.ts +35 -0
- package/agent/apps/desktop/src/themes/user-themes.test.ts +63 -0
- package/agent/apps/desktop/src/themes/user-themes.ts +122 -0
- package/agent/apps/desktop/src/themes/vscode.test.ts +171 -0
- package/agent/apps/desktop/src/themes/vscode.ts +343 -0
- package/agent/apps/desktop/src/types/hermes.ts +138 -1
- package/agent/apps/desktop/tsconfig.json +2 -2
- package/agent/apps/desktop/vite.config.ts +18 -0
- package/agent/apps/shared/package.json +1 -1
- package/agent/apps/shared/src/json-rpc-gateway.ts +63 -2
- package/agent/apps/shared/tsconfig.json +2 -2
- package/agent/cli-config.yaml.example +78 -1
- package/agent/cli.py +2177 -3162
- package/agent/cron/blueprint_catalog.py +713 -0
- package/agent/cron/jobs.py +226 -110
- package/agent/cron/scheduler.py +468 -193
- package/agent/cron/scheduler_provider.py +177 -0
- package/agent/cron/scripts/__init__.py +1 -0
- package/agent/cron/scripts/classify_items.py +226 -0
- package/agent/cron/suggestion_catalog.py +154 -0
- package/agent/cron/suggestions.py +257 -0
- package/agent/docs/chronos-managed-cron-contract.md +196 -0
- package/agent/docs/design/profile-builder.md +146 -0
- package/agent/docs/middleware/README.md +260 -0
- package/agent/docs/observability/README.md +316 -0
- package/agent/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md +240 -0
- package/agent/docs/rca-ssl-cacert-post-git-pull.md +54 -0
- package/agent/docs/relay-connector-contract.md +285 -0
- package/agent/gateway/authz_mixin.py +536 -0
- package/agent/gateway/channel_directory.py +65 -3
- package/agent/gateway/config.py +222 -12
- package/agent/gateway/display_config.py +10 -0
- package/agent/gateway/hooks.py +17 -0
- package/agent/gateway/kanban_watchers.py +1146 -0
- package/agent/gateway/message_timestamps.py +166 -0
- package/agent/gateway/platforms/ADDING_A_PLATFORM.md +29 -0
- package/agent/gateway/platforms/api_server.py +216 -38
- package/agent/gateway/platforms/base.py +210 -58
- package/agent/gateway/platforms/email.py +122 -12
- package/agent/gateway/platforms/feishu.py +80 -11
- package/agent/gateway/platforms/feishu_meeting_invite.py +212 -0
- package/agent/gateway/platforms/matrix.py +1498 -297
- package/agent/gateway/platforms/qqbot/adapter.py +6 -0
- package/agent/gateway/platforms/signal.py +8 -0
- package/agent/gateway/platforms/slack.py +308 -12
- package/agent/gateway/platforms/telegram.py +831 -24
- package/agent/gateway/platforms/webhook.py +109 -21
- package/agent/gateway/platforms/weixin.py +113 -2
- package/agent/gateway/platforms/whatsapp.py +94 -288
- package/agent/gateway/platforms/whatsapp_cloud.py +1956 -0
- package/agent/gateway/platforms/whatsapp_common.py +367 -0
- package/agent/gateway/platforms/yuanbao.py +608 -191
- package/agent/gateway/platforms/yuanbao_proto.py +232 -23
- package/agent/gateway/relay/__init__.py +375 -0
- package/agent/gateway/relay/adapter.py +222 -0
- package/agent/gateway/relay/auth.py +168 -0
- package/agent/gateway/relay/descriptor.py +118 -0
- package/agent/gateway/relay/transport.py +101 -0
- package/agent/gateway/relay/ws_transport.py +327 -0
- package/agent/gateway/response_filters.py +53 -0
- package/agent/gateway/rich_sent_store.py +80 -0
- package/agent/gateway/run.py +2940 -5001
- package/agent/gateway/session.py +109 -8
- package/agent/gateway/session_context.py +22 -4
- package/agent/gateway/slash_commands.py +3854 -0
- package/agent/gateway/status.py +141 -21
- package/agent/gateway/stream_consumer.py +288 -31
- package/agent/hermes-already-has-routines.md +1 -1
- package/agent/hermes_cli/__init__.py +62 -17
- package/agent/hermes_cli/_parser.py +30 -0
- package/agent/hermes_cli/_subprocess_compat.py +61 -0
- package/agent/hermes_cli/active_sessions.py +320 -0
- package/agent/hermes_cli/auth.py +707 -59
- package/agent/hermes_cli/auth_commands.py +39 -22
- package/agent/hermes_cli/backup.py +109 -7
- package/agent/hermes_cli/banner.py +88 -0
- package/agent/hermes_cli/blueprint_cmd.py +318 -0
- package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
- package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
- package/agent/hermes_cli/commands.py +215 -91
- package/agent/hermes_cli/config.py +967 -130
- package/agent/hermes_cli/container_boot.py +76 -11
- package/agent/hermes_cli/cron.py +5 -11
- package/agent/hermes_cli/curator.py +21 -0
- package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
- package/agent/hermes_cli/dashboard_auth/base.py +62 -0
- package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
- package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
- package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
- package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
- package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
- package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
- package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
- package/agent/hermes_cli/dashboard_register.py +427 -0
- package/agent/hermes_cli/debug.py +155 -50
- package/agent/hermes_cli/doctor.py +255 -14
- package/agent/hermes_cli/dump.py +60 -6
- package/agent/hermes_cli/env_loader.py +33 -0
- package/agent/hermes_cli/gateway.py +755 -103
- package/agent/hermes_cli/gateway_enroll.py +250 -0
- package/agent/hermes_cli/gateway_windows.py +254 -11
- package/agent/hermes_cli/gui_uninstall.py +285 -0
- package/agent/hermes_cli/inventory.py +105 -4
- package/agent/hermes_cli/kanban.py +58 -71
- package/agent/hermes_cli/kanban_db.py +391 -14
- package/agent/hermes_cli/kanban_decompose.py +2 -2
- package/agent/hermes_cli/kanban_specify.py +3 -1
- package/agent/hermes_cli/logs.py +2 -0
- package/agent/hermes_cli/main.py +2889 -5287
- package/agent/hermes_cli/managed_scope.py +214 -0
- package/agent/hermes_cli/managed_uv.py +254 -0
- package/agent/hermes_cli/mcp_catalog.py +6 -3
- package/agent/hermes_cli/mcp_config.py +145 -21
- package/agent/hermes_cli/mcp_security.py +96 -0
- package/agent/hermes_cli/mcp_startup.py +32 -3
- package/agent/hermes_cli/memory_providers.py +149 -0
- package/agent/hermes_cli/memory_setup.py +97 -42
- package/agent/hermes_cli/middleware.py +313 -0
- package/agent/hermes_cli/model_catalog.py +31 -0
- package/agent/hermes_cli/model_cost_guard.py +134 -0
- package/agent/hermes_cli/model_normalize.py +2 -1
- package/agent/hermes_cli/model_setup_flows.py +2759 -0
- package/agent/hermes_cli/model_switch.py +242 -27
- package/agent/hermes_cli/models.py +284 -44
- package/agent/hermes_cli/nous_account.py +33 -6
- package/agent/hermes_cli/nous_billing.py +406 -0
- package/agent/hermes_cli/nous_subscription.py +202 -5
- package/agent/hermes_cli/platforms.py +1 -0
- package/agent/hermes_cli/plugins.py +218 -18
- package/agent/hermes_cli/plugins_cmd.py +249 -105
- package/agent/hermes_cli/portal_cli.py +56 -16
- package/agent/hermes_cli/profile_distribution.py +6 -1
- package/agent/hermes_cli/profiles.py +283 -32
- package/agent/hermes_cli/provider_catalog.py +170 -0
- package/agent/hermes_cli/providers.py +4 -1
- package/agent/hermes_cli/pty_bridge.py +53 -4
- package/agent/hermes_cli/runtime_provider.py +216 -34
- package/agent/hermes_cli/secret_prompt.py +4 -4
- package/agent/hermes_cli/secrets_cli.py +24 -0
- package/agent/hermes_cli/send_cmd.py +28 -2
- package/agent/hermes_cli/service_manager.py +166 -19
- package/agent/hermes_cli/session_listing.py +97 -0
- package/agent/hermes_cli/setup.py +158 -94
- package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
- package/agent/hermes_cli/skills_config.py +8 -2
- package/agent/hermes_cli/skills_hub.py +149 -7
- package/agent/hermes_cli/status.py +2 -2
- package/agent/hermes_cli/subcommands/__init__.py +18 -0
- package/agent/hermes_cli/subcommands/_shared.py +29 -0
- package/agent/hermes_cli/subcommands/acp.py +52 -0
- package/agent/hermes_cli/subcommands/auth.py +109 -0
- package/agent/hermes_cli/subcommands/backup.py +38 -0
- package/agent/hermes_cli/subcommands/claw.py +92 -0
- package/agent/hermes_cli/subcommands/config.py +49 -0
- package/agent/hermes_cli/subcommands/cron.py +163 -0
- package/agent/hermes_cli/subcommands/dashboard.py +143 -0
- package/agent/hermes_cli/subcommands/debug.py +77 -0
- package/agent/hermes_cli/subcommands/doctor.py +35 -0
- package/agent/hermes_cli/subcommands/dump.py +28 -0
- package/agent/hermes_cli/subcommands/gateway.py +332 -0
- package/agent/hermes_cli/subcommands/gui.py +63 -0
- package/agent/hermes_cli/subcommands/hooks.py +77 -0
- package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
- package/agent/hermes_cli/subcommands/insights.py +25 -0
- package/agent/hermes_cli/subcommands/login.py +78 -0
- package/agent/hermes_cli/subcommands/logout.py +28 -0
- package/agent/hermes_cli/subcommands/logs.py +78 -0
- package/agent/hermes_cli/subcommands/mcp.py +108 -0
- package/agent/hermes_cli/subcommands/memory.py +53 -0
- package/agent/hermes_cli/subcommands/model.py +72 -0
- package/agent/hermes_cli/subcommands/pairing.py +36 -0
- package/agent/hermes_cli/subcommands/plugins.py +94 -0
- package/agent/hermes_cli/subcommands/postinstall.py +23 -0
- package/agent/hermes_cli/subcommands/profile.py +203 -0
- package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
- package/agent/hermes_cli/subcommands/security.py +62 -0
- package/agent/hermes_cli/subcommands/setup.py +58 -0
- package/agent/hermes_cli/subcommands/skills.py +298 -0
- package/agent/hermes_cli/subcommands/slack.py +60 -0
- package/agent/hermes_cli/subcommands/status.py +28 -0
- package/agent/hermes_cli/subcommands/tools.py +95 -0
- package/agent/hermes_cli/subcommands/uninstall.py +41 -0
- package/agent/hermes_cli/subcommands/update.py +70 -0
- package/agent/hermes_cli/subcommands/version.py +18 -0
- package/agent/hermes_cli/subcommands/webhook.py +76 -0
- package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
- package/agent/hermes_cli/suggestions_cmd.py +153 -0
- package/agent/hermes_cli/telegram_managed_bot.py +358 -0
- package/agent/hermes_cli/tips.py +3 -4
- package/agent/hermes_cli/tools_config.py +155 -28
- package/agent/hermes_cli/uninstall.py +231 -35
- package/agent/hermes_cli/web_server.py +6188 -975
- package/agent/hermes_cli/win_pty_bridge.py +179 -0
- package/agent/hermes_cli/write_approval_commands.py +209 -0
- package/agent/hermes_constants.py +164 -33
- package/agent/hermes_logging.py +74 -2
- package/agent/hermes_state.py +919 -106
- package/agent/hermes_time.py +20 -0
- package/agent/locales/af.yaml +23 -0
- package/agent/locales/de.yaml +23 -0
- package/agent/locales/en.yaml +20 -0
- package/agent/locales/es.yaml +23 -0
- package/agent/locales/fr.yaml +23 -0
- package/agent/locales/ga.yaml +23 -0
- package/agent/locales/hu.yaml +23 -0
- package/agent/locales/it.yaml +23 -0
- package/agent/locales/ja.yaml +23 -0
- package/agent/locales/ko.yaml +23 -0
- package/agent/locales/pt.yaml +23 -0
- package/agent/locales/ru.yaml +23 -0
- package/agent/locales/tr.yaml +23 -0
- package/agent/locales/uk.yaml +23 -0
- package/agent/locales/zh-hant.yaml +23 -0
- package/agent/locales/zh.yaml +23 -0
- package/agent/model_tools.py +204 -40
- package/agent/optional-mcps/clawpump/manifest.yaml +4 -2
- package/agent/optional-mcps/clawpump-stdio/manifest.yaml +2 -0
- package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
- package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
- package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
- package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
- package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
- package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
- package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
- package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
- package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
- package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
- package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
- package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
- package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
- package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
- package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
- package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
- package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
- package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
- package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
- package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
- package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
- package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
- package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
- package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
- package/agent/optional-skills/security/1password/SKILL.md +1 -1
- package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
- package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
- package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
- package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
- package/agent/package-lock.json +4082 -7907
- package/agent/package.json +18 -3
- package/agent/plugins/browser/firecrawl/provider.py +4 -1
- package/agent/plugins/cron/__init__.py +344 -0
- package/agent/plugins/cron/chronos/__init__.py +241 -0
- package/agent/plugins/cron/chronos/_nas_client.py +123 -0
- package/agent/plugins/cron/chronos/plugin.yaml +9 -0
- package/agent/plugins/cron/chronos/verify.py +103 -0
- package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
- package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
- package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
- package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
- package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
- package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
- package/agent/plugins/google_meet/audio_bridge.py +4 -0
- package/agent/plugins/google_meet/meet_bot.py +7 -1
- package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
- package/agent/plugins/image_gen/fal/__init__.py +35 -6
- package/agent/plugins/image_gen/krea/__init__.py +56 -13
- package/agent/plugins/image_gen/openai/__init__.py +122 -24
- package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
- package/agent/plugins/image_gen/xai/__init__.py +92 -12
- package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
- package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
- package/agent/plugins/memory/__init__.py +48 -5
- package/agent/plugins/memory/byterover/__init__.py +1 -0
- package/agent/plugins/memory/hindsight/README.md +1 -1
- package/agent/plugins/memory/hindsight/__init__.py +138 -24
- package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
- package/agent/plugins/memory/honcho/README.md +13 -10
- package/agent/plugins/memory/honcho/cli.py +247 -122
- package/agent/plugins/memory/honcho/client.py +112 -102
- package/agent/plugins/memory/openviking/README.md +12 -1
- package/agent/plugins/memory/openviking/__init__.py +2281 -107
- package/agent/plugins/memory/openviking/plugin.yaml +1 -2
- package/agent/plugins/memory/supermemory/README.md +22 -10
- package/agent/plugins/memory/supermemory/__init__.py +142 -37
- package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
- package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
- package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
- package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
- package/agent/plugins/model-providers/custom/__init__.py +8 -2
- package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
- package/agent/plugins/model-providers/minimax/__init__.py +60 -8
- package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
- package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
- package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
- package/agent/plugins/model-providers/zai/__init__.py +1 -0
- package/agent/plugins/observability/langfuse/__init__.py +147 -14
- package/agent/plugins/observability/nemo_relay/README.md +559 -0
- package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
- package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
- package/agent/plugins/platforms/discord/adapter.py +932 -61
- package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
- package/agent/plugins/platforms/google_chat/adapter.py +9 -3
- package/agent/plugins/platforms/google_chat/oauth.py +1 -1
- package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
- package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
- package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
- package/agent/plugins/platforms/irc/adapter.py +4 -1
- package/agent/plugins/platforms/line/adapter.py +16 -1
- package/agent/plugins/platforms/mattermost/adapter.py +100 -24
- package/agent/plugins/platforms/photon/README.md +179 -0
- package/agent/plugins/platforms/photon/__init__.py +4 -0
- package/agent/plugins/platforms/photon/adapter.py +1586 -0
- package/agent/plugins/platforms/photon/auth.py +1046 -0
- package/agent/plugins/platforms/photon/cli.py +439 -0
- package/agent/plugins/platforms/photon/plugin.yaml +88 -0
- package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
- package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
- package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
- package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
- package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
- package/agent/plugins/platforms/raft/__init__.py +3 -0
- package/agent/plugins/platforms/raft/adapter.py +774 -0
- package/agent/plugins/platforms/raft/plugin.yaml +19 -0
- package/agent/plugins/platforms/simplex/adapter.py +777 -220
- package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
- package/agent/plugins/platforms/teams/adapter.py +175 -5
- package/agent/plugins/plugin_utils.py +135 -0
- package/agent/plugins/video_gen/fal/__init__.py +10 -3
- package/agent/plugins/web/searxng/provider.py +15 -2
- package/agent/plugins/web/xai/provider.py +2 -2
- package/agent/providers/base.py +22 -3
- package/agent/pyproject.toml +115 -21
- package/agent/run_agent.py +733 -39
- package/agent/scripts/build_skills_index.py +51 -19
- package/agent/scripts/check_subprocess_stdin.py +177 -0
- package/agent/scripts/contributor_audit.py +2 -0
- package/agent/scripts/docker_config_migrate.py +67 -0
- package/agent/scripts/install.cmd +3 -3
- package/agent/scripts/install.ps1 +580 -154
- package/agent/scripts/install.sh +402 -185
- package/agent/scripts/lib/node-bootstrap.sh +39 -4
- package/agent/scripts/release.py +183 -0
- package/agent/scripts/run_tests.sh +1 -0
- package/agent/scripts/run_tests_parallel.py +18 -23
- package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
- package/agent/setup.py +59 -0
- package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
- package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
- package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
- package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
- package/agent/skills/clawpump/SKILL.md +3 -1
- package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
- package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
- package/agent/skills/github/github-auth/SKILL.md +2 -2
- package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
- package/agent/skills/github/github-code-review/SKILL.md +2 -2
- package/agent/skills/github/github-issues/SKILL.md +2 -2
- package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
- package/agent/skills/github/github-repo-management/SKILL.md +2 -2
- package/agent/skills/media/gif-search/SKILL.md +1 -1
- package/agent/skills/media/youtube-content/SKILL.md +10 -7
- package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
- package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
- package/agent/skills/productivity/airtable/SKILL.md +2 -2
- package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
- package/agent/skills/productivity/notion/SKILL.md +2 -2
- package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
- package/agent/skills/research/llm-wiki/SKILL.md +1 -1
- package/agent/skills/social-media/xurl/SKILL.md +9 -0
- package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
- package/agent/skills/software-development/plan/SKILL.md +285 -5
- package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
- package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
- package/agent/skills/software-development/spike/SKILL.md +2 -2
- package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
- package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
- package/agent/tools/approval.py +302 -4
- package/agent/tools/async_delegation.py +386 -0
- package/agent/tools/blueprints.py +325 -0
- package/agent/tools/browser_cdp_tool.py +3 -3
- package/agent/tools/browser_tool.py +34 -6
- package/agent/tools/checkpoint_manager.py +31 -1
- package/agent/tools/clarify_tool.py +55 -5
- package/agent/tools/code_execution_tool.py +31 -14
- package/agent/tools/computer_use/cua_backend.py +81 -3
- package/agent/tools/computer_use/tool.py +79 -5
- package/agent/tools/computer_use/vision_routing.py +55 -3
- package/agent/tools/credential_files.py +31 -12
- package/agent/tools/cronjob_tools.py +30 -20
- package/agent/tools/delegate_tool.py +356 -31
- package/agent/tools/env_probe.py +1 -0
- package/agent/tools/environments/docker.py +163 -8
- package/agent/tools/environments/file_sync.py +2 -1
- package/agent/tools/environments/local.py +74 -23
- package/agent/tools/environments/singularity.py +4 -1
- package/agent/tools/environments/ssh.py +78 -11
- package/agent/tools/file_operations.py +277 -41
- package/agent/tools/file_tools.py +166 -28
- package/agent/tools/image_generation_tool.py +515 -29
- package/agent/tools/kanban_tools.py +99 -0
- package/agent/tools/lazy_deps.py +33 -2
- package/agent/tools/mcp_oauth.py +5 -5
- package/agent/tools/mcp_oauth_manager.py +7 -5
- package/agent/tools/mcp_tool.py +840 -33
- package/agent/tools/memory_tool.py +335 -38
- package/agent/tools/osv_check.py +15 -1
- package/agent/tools/process_registry.py +155 -11
- package/agent/tools/read_extract.py +248 -0
- package/agent/tools/read_terminal_tool.py +93 -0
- package/agent/tools/schema_sanitizer.py +38 -0
- package/agent/tools/send_message_tool.py +163 -49
- package/agent/tools/session_search_tool.py +189 -7
- package/agent/tools/skill_manager_tool.py +202 -3
- package/agent/tools/skill_usage.py +52 -4
- package/agent/tools/skills_hub.py +184 -44
- package/agent/tools/skills_sync.py +232 -5
- package/agent/tools/skills_tool.py +125 -11
- package/agent/tools/terminal_tool.py +148 -26
- package/agent/tools/tirith_security.py +2 -0
- package/agent/tools/todo_tool.py +32 -1
- package/agent/tools/transcription_tools.py +13 -5
- package/agent/tools/tts_tool.py +332 -38
- package/agent/tools/url_safety.py +52 -1
- package/agent/tools/vision_tools.py +124 -39
- package/agent/tools/voice_mode.py +4 -3
- package/agent/tools/web_tools.py +45 -15
- package/agent/tools/write_approval.py +493 -0
- package/agent/toolsets.py +34 -10
- package/agent/trajectory_compressor.py +81 -10
- package/agent/tui_gateway/entry.py +43 -6
- package/agent/tui_gateway/server.py +3335 -330
- package/agent/tui_gateway/slash_worker.py +61 -0
- package/agent/tui_gateway/ws.py +67 -9
- package/agent/ui-tui/eslint.config.mjs +0 -4
- package/agent/ui-tui/package.json +6 -6
- package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
- package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
- package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
- package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
- package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
- package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
- package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
- package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
- package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
- package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
- package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
- package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
- package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
- package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
- package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
- package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
- package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
- package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
- package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
- package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
- package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
- package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
- package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
- package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
- package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
- package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
- package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
- package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
- package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
- package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
- package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
- package/agent/ui-tui/src/app/interfaces.ts +64 -1
- package/agent/ui-tui/src/app/overlayStore.ts +18 -2
- package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
- package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
- package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
- package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
- package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
- package/agent/ui-tui/src/app/slash/registry.ts +4 -0
- package/agent/ui-tui/src/app/turnController.ts +145 -2
- package/agent/ui-tui/src/app/uiStore.ts +2 -0
- package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
- package/agent/ui-tui/src/app/useMainApp.ts +54 -8
- package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
- package/agent/ui-tui/src/app/useSubmission.ts +23 -31
- package/agent/ui-tui/src/components/appChrome.tsx +112 -5
- package/agent/ui-tui/src/components/appLayout.tsx +9 -0
- package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
- package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
- package/agent/ui-tui/src/components/branding.tsx +15 -3
- package/agent/ui-tui/src/components/messageLine.tsx +25 -3
- package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
- package/agent/ui-tui/src/components/prompts.tsx +31 -17
- package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
- package/agent/ui-tui/src/components/textInput.tsx +16 -0
- package/agent/ui-tui/src/config/env.ts +12 -0
- package/agent/ui-tui/src/config/limits.ts +13 -0
- package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
- package/agent/ui-tui/src/domain/paths.ts +24 -0
- package/agent/ui-tui/src/domain/slash.ts +40 -0
- package/agent/ui-tui/src/entry.tsx +35 -4
- package/agent/ui-tui/src/gatewayClient.ts +22 -10
- package/agent/ui-tui/src/gatewayTypes.ts +130 -1
- package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
- package/agent/ui-tui/src/lib/memory.test.ts +162 -0
- package/agent/ui-tui/src/lib/memory.ts +60 -1
- package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
- package/agent/ui-tui/src/lib/osc52.ts +1 -1
- package/agent/ui-tui/src/lib/text.test.ts +32 -1
- package/agent/ui-tui/src/lib/text.ts +29 -2
- package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
- package/agent/ui-tui/src/types.ts +5 -0
- package/agent/ui-tui/tsconfig.build.json +0 -1
- package/agent/ui-tui/tsconfig.json +2 -1
- package/agent/utils.py +66 -2
- package/agent/uv.lock +300 -684
- package/agent/web/index.html +2 -2
- package/agent/web/package.json +11 -6
- package/agent/web/public/claw-bg.webp +0 -0
- package/agent/web/public/claw-logo.webp +0 -0
- package/agent/web/src/App.tsx +138 -48
- package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
- package/agent/web/src/components/Backdrop.tsx +15 -0
- package/agent/web/src/components/ChatSessionList.tsx +260 -0
- package/agent/web/src/components/ChatSidebar.tsx +262 -78
- package/agent/web/src/components/ConfirmDialog.tsx +122 -0
- package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
- package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
- package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
- package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
- package/agent/web/src/components/ReasoningPicker.tsx +167 -0
- package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
- package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
- package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
- package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
- package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
- package/agent/web/src/contexts/SystemActions.tsx +6 -8
- package/agent/web/src/contexts/profile-context.ts +19 -0
- package/agent/web/src/contexts/useProfileScope.ts +6 -0
- package/agent/web/src/i18n/af.ts +5 -4
- package/agent/web/src/i18n/de.ts +5 -4
- package/agent/web/src/i18n/en.ts +58 -4
- package/agent/web/src/i18n/es.ts +5 -3
- package/agent/web/src/i18n/fr.ts +5 -3
- package/agent/web/src/i18n/ga.ts +5 -4
- package/agent/web/src/i18n/hu.ts +5 -4
- package/agent/web/src/i18n/it.ts +5 -4
- package/agent/web/src/i18n/ja.ts +5 -4
- package/agent/web/src/i18n/ko.ts +5 -4
- package/agent/web/src/i18n/pt.ts +5 -3
- package/agent/web/src/i18n/ru.ts +5 -4
- package/agent/web/src/i18n/tr.ts +5 -4
- package/agent/web/src/i18n/types.ts +59 -1
- package/agent/web/src/i18n/uk.ts +5 -3
- package/agent/web/src/i18n/zh-hant.ts +5 -4
- package/agent/web/src/i18n/zh.ts +5 -4
- package/agent/web/src/index.css +2 -2
- package/agent/web/src/lib/api.ts +819 -52
- package/agent/web/src/lib/dashboard-flags.ts +16 -7
- package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
- package/agent/web/src/lib/reasoning-effort.ts +36 -0
- package/agent/web/src/lib/session-refresh.test.ts +21 -0
- package/agent/web/src/lib/session-refresh.ts +26 -0
- package/agent/web/src/pages/ChannelsPage.tsx +529 -68
- package/agent/web/src/pages/ChatPage.tsx +249 -56
- package/agent/web/src/pages/ConfigPage.tsx +11 -1
- package/agent/web/src/pages/CronPage.tsx +219 -31
- package/agent/web/src/pages/EnvPage.tsx +25 -6
- package/agent/web/src/pages/FilesPage.tsx +525 -0
- package/agent/web/src/pages/McpPage.tsx +80 -3
- package/agent/web/src/pages/ModelsPage.tsx +97 -12
- package/agent/web/src/pages/PluginsPage.tsx +1 -1
- package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
- package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
- package/agent/web/src/pages/SessionsPage.tsx +144 -13
- package/agent/web/src/pages/SkillsPage.tsx +851 -70
- package/agent/web/src/pages/SystemPage.tsx +340 -4
- package/agent/web/src/pages/WalletPage.tsx +401 -0
- package/agent/web/src/pages/WebhooksPage.tsx +145 -15
- package/agent/web/src/pages/X402Page.tsx +207 -0
- package/agent/web/src/plugins/registry.ts +28 -11
- package/agent/web/src/plugins/sdk.d.ts +160 -0
- package/agent/web/src/themes/context.tsx +112 -5
- package/agent/web/src/themes/fonts.ts +167 -0
- package/agent/web/src/themes/index.ts +7 -0
- package/agent/web/tsconfig.app.json +0 -1
- package/agent/web/vite.config.ts +1 -8
- package/agent/web/vitest.config.ts +16 -0
- package/package.json +1 -1
- package/agent/apps/desktop/package-lock.json +0 -18363
- package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
- package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
- package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
- package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
- package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
- package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
- package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
- package/agent/skills/diagramming/DESCRIPTION.md +0 -3
- package/agent/skills/domain/DESCRIPTION.md +0 -24
- package/agent/skills/gifs/DESCRIPTION.md +0 -3
- package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
- package/agent/skills/mcp/DESCRIPTION.md +0 -3
- package/agent/skills/media/spotify/SKILL.md +0 -135
- package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
- package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
- package/agent/skills/productivity/linear/SKILL.md +0 -380
- package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
- package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
- package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
- package/agent/ui-tui/package-lock.json +0 -7449
- package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
- package/agent/web/package-lock.json +0 -8887
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
- /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
- /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
- /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
|
|
21
|
-
|
|
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
|
|
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__(
|
|
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
|
-
|
|
306
|
-
|
|
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:
|
|
732
|
+
"Matrix: E2EE is required but dependencies are missing. %s. "
|
|
315
733
|
"Without this, encrypted rooms will not work. "
|
|
316
|
-
"Set
|
|
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.
|
|
373
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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:
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
1310
|
+
await olm.share_keys()
|
|
859
1311
|
except Exception as exc:
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1081
|
-
chat_type = "dm" if
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
1283
|
-
"
|
|
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
|
-
"✅ =
|
|
1286
|
-
"❎ =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
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
|
|
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
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
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
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
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)
|