@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
|
@@ -14,22 +14,75 @@ import hashlib
|
|
|
14
14
|
import json
|
|
15
15
|
import logging
|
|
16
16
|
import os
|
|
17
|
+
import re
|
|
17
18
|
import struct
|
|
18
19
|
import subprocess
|
|
19
20
|
import tempfile
|
|
20
21
|
import threading
|
|
21
22
|
import time
|
|
22
23
|
from collections import defaultdict
|
|
24
|
+
from contextlib import suppress
|
|
23
25
|
from typing import Callable, Dict, List, Optional, Any, Tuple
|
|
24
26
|
|
|
25
27
|
logger = logging.getLogger(__name__)
|
|
26
28
|
|
|
29
|
+
|
|
30
|
+
class _Snowflake:
|
|
31
|
+
"""Minimal object exposing ``.id`` — satisfies discord.py's Snowflake
|
|
32
|
+
protocol for ``channel.history(before=...)`` without constructing a
|
|
33
|
+
``discord.Object`` (which test doubles that stub the discord module
|
|
34
|
+
cannot build). Used to anchor reply-context scans inclusively.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("id",)
|
|
38
|
+
|
|
39
|
+
def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
|
|
40
|
+
self.id = id
|
|
41
|
+
|
|
27
42
|
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
|
|
28
43
|
_DISCORD_COMMAND_SYNC_POLICIES = {"safe", "bulk", "off"}
|
|
29
44
|
_DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway"
|
|
30
45
|
_DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json"
|
|
46
|
+
_DISCORD_NONCONVERSATIONAL_STATE_FILENAME = "discord_nonconversational_messages.json"
|
|
31
47
|
_DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5
|
|
32
48
|
_DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0
|
|
49
|
+
# Discord enforces a hard cap of 100 global application (slash) commands per
|
|
50
|
+
# app. Registering more makes the ENTIRE sync fail with error 30032
|
|
51
|
+
# ("Maximum number of application commands reached"), which silently breaks
|
|
52
|
+
# every slash command — not just the overflow ones. We keep the desired set
|
|
53
|
+
# at or below this limit at registration time.
|
|
54
|
+
_DISCORD_MAX_APP_COMMANDS = 100
|
|
55
|
+
_DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({
|
|
56
|
+
"non_conversational",
|
|
57
|
+
"non_conversational_history",
|
|
58
|
+
})
|
|
59
|
+
# Upgrade-bridge fallback only. The primary mechanism is the persisted
|
|
60
|
+
# non-conversational message-ID set populated from explicitly marked sends
|
|
61
|
+
# (metadata["non_conversational"]). These regexes exist solely to recognize
|
|
62
|
+
# status bumps emitted by an older gateway version that pre-dates the marking,
|
|
63
|
+
# so they don't partition history after an upgrade. New emitters should set the
|
|
64
|
+
# metadata flag, not rely on a regex here.
|
|
65
|
+
_DISCORD_NONCONVERSATIONAL_HISTORY_MESSAGE_PATTERNS = (
|
|
66
|
+
re.compile(r"^\s*💾\s*Self-improvement review:\s+\S[\s\S]*$", re.IGNORECASE),
|
|
67
|
+
# Legacy/background-review test doubles used this shorter form before the
|
|
68
|
+
# self-improvement prefix became the stable emitter contract.
|
|
69
|
+
re.compile(
|
|
70
|
+
r"^\s*💾\s+Skill\s+['\"].+?['\"]\s+(?:created|updated|improved|patched)\.?\s*$",
|
|
71
|
+
re.IGNORECASE,
|
|
72
|
+
),
|
|
73
|
+
re.compile(r"^\s*⏳\s+Working\s+—\s+\d+\s+min(?:\s|$)", re.IGNORECASE),
|
|
74
|
+
re.compile(
|
|
75
|
+
r"^\s*\[Background process\s+\S+\s+"
|
|
76
|
+
r"(?:finished with exit code|is still running~)[\s\S]*\]\s*$",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
),
|
|
79
|
+
re.compile(
|
|
80
|
+
r"^\s*(?:✅|❌)\s+Hermes update\s+"
|
|
81
|
+
r"(?:finished|failed|timed out)[\s\S]*$",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
),
|
|
84
|
+
re.compile(r"^\s*♻️?\s+Gateway\s+(?:restarted successfully|online\b)[\s\S]*$", re.IGNORECASE),
|
|
85
|
+
)
|
|
33
86
|
|
|
34
87
|
try:
|
|
35
88
|
import discord
|
|
@@ -48,7 +101,6 @@ from pathlib import Path as _Path
|
|
|
48
101
|
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
|
49
102
|
|
|
50
103
|
from gateway.config import Platform, PlatformConfig
|
|
51
|
-
import re
|
|
52
104
|
|
|
53
105
|
from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker
|
|
54
106
|
from utils import atomic_json_write
|
|
@@ -68,6 +120,43 @@ from gateway.platforms.base import (
|
|
|
68
120
|
from tools.url_safety import is_safe_url
|
|
69
121
|
|
|
70
122
|
|
|
123
|
+
async def _wait_for_ready_or_bot_exit(
|
|
124
|
+
ready_event: asyncio.Event,
|
|
125
|
+
bot_task: asyncio.Task,
|
|
126
|
+
timeout: float,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Wait until Discord is ready, or surface early bot startup failure.
|
|
129
|
+
|
|
130
|
+
``discord.py`` startup errors (including SOCKS/proxy failures from
|
|
131
|
+
aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()``
|
|
132
|
+
only waits on ``ready_event``, a dead background task still burns the full
|
|
133
|
+
ready timeout before the gateway supervisor can reconnect. Racing the ready
|
|
134
|
+
event against the bot task keeps failures fast and preserves the original
|
|
135
|
+
exception for logging/classification.
|
|
136
|
+
"""
|
|
137
|
+
ready_task = asyncio.create_task(ready_event.wait())
|
|
138
|
+
try:
|
|
139
|
+
done, _pending = await asyncio.wait(
|
|
140
|
+
{ready_task, bot_task},
|
|
141
|
+
timeout=timeout,
|
|
142
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
143
|
+
)
|
|
144
|
+
if not done:
|
|
145
|
+
raise asyncio.TimeoutError
|
|
146
|
+
if bot_task in done:
|
|
147
|
+
exc = bot_task.exception()
|
|
148
|
+
if exc is not None:
|
|
149
|
+
raise exc
|
|
150
|
+
if not ready_task.done():
|
|
151
|
+
raise RuntimeError("Discord bot task exited before ready")
|
|
152
|
+
await ready_task
|
|
153
|
+
finally:
|
|
154
|
+
if not ready_task.done():
|
|
155
|
+
ready_task.cancel()
|
|
156
|
+
with suppress(asyncio.CancelledError):
|
|
157
|
+
await ready_task
|
|
158
|
+
|
|
159
|
+
|
|
71
160
|
def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]:
|
|
72
161
|
"""Return discord.py's bundled Windows opus DLL path when present."""
|
|
73
162
|
if sys.platform != "win32":
|
|
@@ -88,6 +177,73 @@ def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[s
|
|
|
88
177
|
return None
|
|
89
178
|
|
|
90
179
|
|
|
180
|
+
class _DiscordNonConversationalMessageTracker:
|
|
181
|
+
"""Persistent bounded set of Discord message IDs that are status noise."""
|
|
182
|
+
|
|
183
|
+
_MAX_TRACKED = 2000
|
|
184
|
+
|
|
185
|
+
def __init__(self, max_tracked: int = _MAX_TRACKED):
|
|
186
|
+
self._max_tracked = max_tracked
|
|
187
|
+
self._ids: dict[str, None] = dict.fromkeys(self._load())
|
|
188
|
+
|
|
189
|
+
def _state_path(self) -> _Path:
|
|
190
|
+
from hermes_constants import get_hermes_home
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
get_hermes_home()
|
|
194
|
+
/ _DISCORD_COMMAND_SYNC_STATE_SUBDIR
|
|
195
|
+
/ _DISCORD_NONCONVERSATIONAL_STATE_FILENAME
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _load(self) -> list[str]:
|
|
199
|
+
path = self._state_path()
|
|
200
|
+
if not path.exists():
|
|
201
|
+
return []
|
|
202
|
+
try:
|
|
203
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
204
|
+
if isinstance(data, list):
|
|
205
|
+
return [str(message_id) for message_id in data if str(message_id).strip()]
|
|
206
|
+
except Exception:
|
|
207
|
+
logger.debug("[%s] Failed to load non-conversational Discord IDs", "Discord")
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
def _save(self) -> None:
|
|
211
|
+
ids = list(self._ids)
|
|
212
|
+
if len(ids) > self._max_tracked:
|
|
213
|
+
ids = ids[-self._max_tracked:]
|
|
214
|
+
self._ids = dict.fromkeys(ids)
|
|
215
|
+
try:
|
|
216
|
+
atomic_json_write(self._state_path(), ids, indent=None)
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.debug("[%s] Failed to save non-conversational Discord IDs", "Discord", exc_info=True)
|
|
219
|
+
|
|
220
|
+
def mark_many(self, message_ids: List[str]) -> None:
|
|
221
|
+
changed = False
|
|
222
|
+
for message_id in message_ids:
|
|
223
|
+
key = str(message_id or "").strip()
|
|
224
|
+
if key and key not in self._ids:
|
|
225
|
+
self._ids[key] = None
|
|
226
|
+
changed = True
|
|
227
|
+
if changed:
|
|
228
|
+
self._save()
|
|
229
|
+
|
|
230
|
+
def __contains__(self, message_id: str) -> bool:
|
|
231
|
+
return str(message_id or "") in self._ids
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _metadata_marks_nonconversational(metadata: Optional[Dict[str, Any]]) -> bool:
|
|
235
|
+
"""Return True when an outbound send was explicitly marked as status-only."""
|
|
236
|
+
if not isinstance(metadata, dict):
|
|
237
|
+
return False
|
|
238
|
+
return any(bool(metadata.get(key)) for key in _DISCORD_NONCONVERSATIONAL_METADATA_KEYS)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _looks_like_nonconversational_history_message(content: str) -> bool:
|
|
242
|
+
"""Fallback recognizer for legacy status bumps missing persisted IDs."""
|
|
243
|
+
text = content or ""
|
|
244
|
+
return any(pattern.match(text) for pattern in _DISCORD_NONCONVERSATIONAL_HISTORY_MESSAGE_PATTERNS)
|
|
245
|
+
|
|
246
|
+
|
|
91
247
|
def _clean_discord_id(entry: str) -> str:
|
|
92
248
|
"""Strip common prefixes from a Discord user ID or username entry.
|
|
93
249
|
|
|
@@ -520,6 +676,7 @@ class VoiceReceiver:
|
|
|
520
676
|
],
|
|
521
677
|
check=True,
|
|
522
678
|
timeout=10,
|
|
679
|
+
stdin=subprocess.DEVNULL,
|
|
523
680
|
)
|
|
524
681
|
finally:
|
|
525
682
|
try:
|
|
@@ -573,6 +730,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
573
730
|
# Discord message limits
|
|
574
731
|
MAX_MESSAGE_LENGTH = 2000
|
|
575
732
|
_SPLIT_THRESHOLD = 1900 # near the 2000-char split point
|
|
733
|
+
supports_code_blocks = True # Discord markdown renders fenced code blocks natively
|
|
576
734
|
|
|
577
735
|
# Auto-disconnect from voice channel after this many seconds of inactivity
|
|
578
736
|
VOICE_TIMEOUT = 300
|
|
@@ -600,6 +758,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
600
758
|
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
|
|
601
759
|
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
|
602
760
|
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
|
761
|
+
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
|
|
762
|
+
# linked text-channel id; set by run.py. Lets the inactivity timer leave
|
|
763
|
+
# the bot in the channel when the user deliberately picked text-only
|
|
764
|
+
# (/voice off) instead of leaving (/voice leave).
|
|
765
|
+
self._voice_mode_getter: Optional[Callable] = None # set by run.py
|
|
766
|
+
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
|
|
767
|
+
# Installed once per guild on join; lets acks / TTS / the "thinking"
|
|
768
|
+
# loop overlap in one outgoing stream instead of stop-and-swap.
|
|
769
|
+
self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer
|
|
770
|
+
self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed
|
|
771
|
+
self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config()
|
|
603
772
|
# Track threads where the bot has participated so follow-up messages
|
|
604
773
|
# in those threads don't require @mention. Persisted to disk so the
|
|
605
774
|
# set survives gateway restarts.
|
|
@@ -609,6 +778,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
609
778
|
self._typing_tasks: Dict[str, asyncio.Task] = {}
|
|
610
779
|
self._bot_task: Optional[asyncio.Task] = None
|
|
611
780
|
self._post_connect_task: Optional[asyncio.Task] = None
|
|
781
|
+
# True while disconnect() is intentionally closing discord.py. The
|
|
782
|
+
# bot task's done callback uses this to distinguish an operator/service
|
|
783
|
+
# shutdown from a runtime websocket crash.
|
|
784
|
+
self._disconnecting = False
|
|
612
785
|
# Dedup cache: prevents duplicate bot responses when Discord
|
|
613
786
|
# RESUME replays events after reconnects.
|
|
614
787
|
self._dedup = MessageDeduplicator()
|
|
@@ -620,6 +793,68 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
620
793
|
# history backfill to skip the full scan on hot paths. Falls back to
|
|
621
794
|
# scanning channel.history() on cache miss (cold start / restart).
|
|
622
795
|
self._last_self_message_id: Dict[str, str] = {}
|
|
796
|
+
# Persistent set of bot-authored lifecycle/status message IDs that
|
|
797
|
+
# should not act as conversational history boundaries after restart.
|
|
798
|
+
self._nonconversational_messages = _DiscordNonConversationalMessageTracker()
|
|
799
|
+
|
|
800
|
+
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
|
|
801
|
+
"""Surface post-startup discord.py task exits to the gateway supervisor.
|
|
802
|
+
|
|
803
|
+
discord.py reconnects normal gateway interruptions internally. When its
|
|
804
|
+
top-level ``Bot.start()`` task actually exits after the adapter has been
|
|
805
|
+
marked running, the Discord websocket is dead while the Hermes gateway
|
|
806
|
+
process can remain alive. Treat that split-brain state as a retryable
|
|
807
|
+
fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can
|
|
808
|
+
remove this adapter and queue Discord for the existing reconnect watcher.
|
|
809
|
+
"""
|
|
810
|
+
if getattr(self, "_disconnecting", False):
|
|
811
|
+
# Intentional service/operator shutdown. Drain the task result so
|
|
812
|
+
# asyncio doesn't emit "exception was never retrieved" warnings.
|
|
813
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
814
|
+
task.exception()
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
# Ignore stale callbacks from an older client if a reconnect already
|
|
818
|
+
# installed a newer Bot.start() task on this adapter instance.
|
|
819
|
+
if self._bot_task is not None and task is not self._bot_task:
|
|
820
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
821
|
+
task.exception()
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
if not self._running:
|
|
825
|
+
# Startup failures are handled by _wait_for_ready_or_bot_exit() in
|
|
826
|
+
# connect(); this callback is only for post-startup split-brain.
|
|
827
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
828
|
+
task.exception()
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
try:
|
|
832
|
+
exc = task.exception()
|
|
833
|
+
except asyncio.CancelledError:
|
|
834
|
+
return
|
|
835
|
+
except Exception as err: # pragma: no cover - defensive
|
|
836
|
+
exc = err
|
|
837
|
+
|
|
838
|
+
if exc is None:
|
|
839
|
+
message = "Discord gateway task exited without an exception"
|
|
840
|
+
else:
|
|
841
|
+
message = f"Discord gateway task exited: {exc}"
|
|
842
|
+
|
|
843
|
+
logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False)
|
|
844
|
+
self._set_fatal_error("discord_gateway_task_exited", message, retryable=True)
|
|
845
|
+
|
|
846
|
+
async def _notify() -> None:
|
|
847
|
+
try:
|
|
848
|
+
await self._notify_fatal_error()
|
|
849
|
+
except Exception as notify_exc: # pragma: no cover - defensive logging
|
|
850
|
+
logger.warning(
|
|
851
|
+
"[%s] Failed to notify gateway supervisor about Discord task exit: %s",
|
|
852
|
+
self.name,
|
|
853
|
+
notify_exc,
|
|
854
|
+
exc_info=True,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
asyncio.create_task(_notify())
|
|
623
858
|
|
|
624
859
|
async def connect(self) -> bool:
|
|
625
860
|
"""Connect to Discord and start receiving events."""
|
|
@@ -781,6 +1016,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
781
1016
|
# Must run BEFORE the user allowlist check so that bots
|
|
782
1017
|
# permitted by DISCORD_ALLOW_BOTS are not rejected for
|
|
783
1018
|
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
|
|
1019
|
+
_role_authorized = False
|
|
784
1020
|
if getattr(message.author, "bot", False):
|
|
785
1021
|
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
|
|
786
1022
|
if allow_bots == "none":
|
|
@@ -804,6 +1040,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
804
1040
|
is_dm=_is_dm,
|
|
805
1041
|
):
|
|
806
1042
|
return
|
|
1043
|
+
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
|
|
807
1044
|
|
|
808
1045
|
# Multi-agent filtering: if the message mentions specific bots
|
|
809
1046
|
# but NOT this bot, the sender is talking to another agent —
|
|
@@ -845,7 +1082,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
845
1082
|
if "*" not in _free_channels and not (_channel_ids & _free_channels):
|
|
846
1083
|
return
|
|
847
1084
|
|
|
848
|
-
await self._handle_message(message)
|
|
1085
|
+
await self._handle_message(message, role_authorized=_role_authorized)
|
|
849
1086
|
|
|
850
1087
|
@self._client.event
|
|
851
1088
|
async def on_voice_state_update(member, before, after):
|
|
@@ -885,25 +1122,55 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
885
1122
|
self._register_slash_commands()
|
|
886
1123
|
|
|
887
1124
|
# Start the bot in background
|
|
1125
|
+
self._disconnecting = False
|
|
888
1126
|
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
|
|
1127
|
+
self._bot_task.add_done_callback(self._handle_bot_task_done)
|
|
889
1128
|
|
|
890
|
-
# Wait for ready
|
|
891
|
-
|
|
1129
|
+
# Wait for ready, but fail fast if discord.py's background startup
|
|
1130
|
+
# task dies first (for example on SOCKS/proxy connect errors).
|
|
1131
|
+
await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30)
|
|
892
1132
|
|
|
893
1133
|
self._running = True
|
|
894
1134
|
return True
|
|
895
1135
|
|
|
896
1136
|
except asyncio.TimeoutError:
|
|
897
1137
|
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
|
|
1138
|
+
# Cancel the background bot task so it cannot fire on_message after
|
|
1139
|
+
# this adapter is discarded. Without this, the task keeps running and
|
|
1140
|
+
# a later successful reconnect leaves two active Discord clients that
|
|
1141
|
+
# each process every message, producing duplicate threads/responses.
|
|
1142
|
+
await self._cancel_bot_task()
|
|
898
1143
|
self._release_platform_lock()
|
|
899
1144
|
return False
|
|
900
1145
|
except Exception as e: # pragma: no cover - defensive logging
|
|
901
1146
|
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
|
|
1147
|
+
# Same zombie-client hazard as the timeout branch: the background
|
|
1148
|
+
# client.start() task may already be running when a later setup
|
|
1149
|
+
# step raises. Cancel it so the discarded adapter cannot connect.
|
|
1150
|
+
await self._cancel_bot_task()
|
|
902
1151
|
self._release_platform_lock()
|
|
903
1152
|
return False
|
|
904
1153
|
|
|
1154
|
+
async def _cancel_bot_task(self) -> None:
|
|
1155
|
+
"""Cancel and await the background client.start() task, if running."""
|
|
1156
|
+
if self._bot_task and not self._bot_task.done():
|
|
1157
|
+
self._bot_task.cancel()
|
|
1158
|
+
try:
|
|
1159
|
+
await self._bot_task
|
|
1160
|
+
except (asyncio.CancelledError, Exception):
|
|
1161
|
+
pass
|
|
1162
|
+
self._bot_task = None
|
|
1163
|
+
|
|
905
1164
|
async def disconnect(self) -> None:
|
|
906
1165
|
"""Disconnect from Discord."""
|
|
1166
|
+
self._disconnecting = True
|
|
1167
|
+
# Cancel the bot task before closing the client. If connect() timed out
|
|
1168
|
+
# and returned False, the background client.start() task may still be
|
|
1169
|
+
# running; calling client.close() alone is not enough to stop it because
|
|
1170
|
+
# discord.py's reconnect loop can ignore the closed flag while a
|
|
1171
|
+
# WebSocket handshake is in flight. Explicitly cancelling the task here
|
|
1172
|
+
# ensures the zombie client cannot receive or dispatch any further events.
|
|
1173
|
+
await self._cancel_bot_task()
|
|
907
1174
|
# Clean up all active voice connections before closing the client
|
|
908
1175
|
for guild_id in list(self._voice_clients.keys()):
|
|
909
1176
|
try:
|
|
@@ -1425,6 +1692,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
1425
1692
|
thread_id = None
|
|
1426
1693
|
if metadata and metadata.get("thread_id"):
|
|
1427
1694
|
thread_id = metadata["thread_id"]
|
|
1695
|
+
nonconversational = _metadata_marks_nonconversational(metadata)
|
|
1428
1696
|
|
|
1429
1697
|
if thread_id:
|
|
1430
1698
|
# Fetch the thread directly — threads are addressed by their own ID.
|
|
@@ -1502,7 +1770,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
1502
1770
|
# backfill — avoids a full channel.history() scan on hot paths.
|
|
1503
1771
|
if message_ids:
|
|
1504
1772
|
_target_id = thread_id or chat_id
|
|
1505
|
-
|
|
1773
|
+
if nonconversational:
|
|
1774
|
+
self._nonconversational_messages.mark_many(message_ids)
|
|
1775
|
+
elif not _looks_like_nonconversational_history_message(content):
|
|
1776
|
+
self._last_self_message_id[_target_id] = message_ids[-1]
|
|
1506
1777
|
|
|
1507
1778
|
return SendResult(
|
|
1508
1779
|
success=True,
|
|
@@ -1925,6 +2196,160 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
1925
2196
|
# Voice channel methods (join / leave / play)
|
|
1926
2197
|
# ------------------------------------------------------------------
|
|
1927
2198
|
|
|
2199
|
+
def _load_voice_fx_config(self) -> Dict[str, Any]:
|
|
2200
|
+
"""Read voice mixer / ambient / ack settings from config.yaml.
|
|
2201
|
+
|
|
2202
|
+
All settings live under ``discord.voice_fx`` in config.yaml (NOT the
|
|
2203
|
+
.env file — these are behavioral, not secrets). The feature is OFF by
|
|
2204
|
+
default; users opt in with ``discord.voice_fx.enabled: true``.
|
|
2205
|
+
|
|
2206
|
+
Returns a dict with safe defaults so callers never KeyError.
|
|
2207
|
+
"""
|
|
2208
|
+
defaults: Dict[str, Any] = {
|
|
2209
|
+
"enabled": False, # master switch for the mixer subsystem
|
|
2210
|
+
"ambient_enabled": True, # idle "thinking" bed while tools run
|
|
2211
|
+
"ambient_path": "", # optional custom loop file; "" = synthesised
|
|
2212
|
+
"ambient_gain": 0.18, # idle bed loudness (0..1)
|
|
2213
|
+
"duck_gain": 0.06, # ambient loudness while speech plays
|
|
2214
|
+
"speech_gain": 1.0, # TTS / ack loudness
|
|
2215
|
+
"ack_enabled": True, # speak a short phrase before tool calls
|
|
2216
|
+
"ack_phrases": [
|
|
2217
|
+
"Let me look into that.",
|
|
2218
|
+
"One moment.",
|
|
2219
|
+
"Checking on that now.",
|
|
2220
|
+
"Give me a sec.",
|
|
2221
|
+
"On it.",
|
|
2222
|
+
],
|
|
2223
|
+
}
|
|
2224
|
+
try:
|
|
2225
|
+
from hermes_cli.config import read_raw_config
|
|
2226
|
+
cfg = read_raw_config() or {}
|
|
2227
|
+
fx = ((cfg.get("discord") or {}).get("voice_fx") or {})
|
|
2228
|
+
if isinstance(fx, dict):
|
|
2229
|
+
for k, v in fx.items():
|
|
2230
|
+
if k in defaults and v is not None:
|
|
2231
|
+
defaults[k] = v
|
|
2232
|
+
except Exception as e:
|
|
2233
|
+
logger.debug("Could not load discord.voice_fx config: %s", e)
|
|
2234
|
+
return defaults
|
|
2235
|
+
|
|
2236
|
+
def _get_ambient_pcm(self) -> Optional[bytes]:
|
|
2237
|
+
"""Return decoded 48k/stereo/s16le PCM for the ambient idle bed.
|
|
2238
|
+
|
|
2239
|
+
Uses a custom file when ``ambient_path`` is set and decodable, else a
|
|
2240
|
+
synthesised pad. Cached after first build.
|
|
2241
|
+
"""
|
|
2242
|
+
if self._ambient_pcm_cache is not None:
|
|
2243
|
+
return self._ambient_pcm_cache
|
|
2244
|
+
if not self._voice_fx_cfg.get("ambient_enabled"):
|
|
2245
|
+
return None
|
|
2246
|
+
try:
|
|
2247
|
+
from voice_mixer import decode_to_pcm, synth_ambient_pcm
|
|
2248
|
+
except ImportError:
|
|
2249
|
+
from .voice_mixer import decode_to_pcm, synth_ambient_pcm
|
|
2250
|
+
|
|
2251
|
+
pcm: Optional[bytes] = None
|
|
2252
|
+
path = (self._voice_fx_cfg.get("ambient_path") or "").strip()
|
|
2253
|
+
if path and os.path.isfile(path):
|
|
2254
|
+
pcm = decode_to_pcm(path)
|
|
2255
|
+
if not pcm:
|
|
2256
|
+
logger.warning("Ambient file %s failed to decode; using synth bed", path)
|
|
2257
|
+
if not pcm:
|
|
2258
|
+
pcm = synth_ambient_pcm()
|
|
2259
|
+
self._ambient_pcm_cache = pcm
|
|
2260
|
+
return pcm
|
|
2261
|
+
|
|
2262
|
+
async def _install_voice_mixer(self, guild_id: int, vc) -> None:
|
|
2263
|
+
"""Create a VoiceMixer, start the ambient bed, and play it on the VC.
|
|
2264
|
+
|
|
2265
|
+
The mixer runs continuously for the life of the connection: one
|
|
2266
|
+
``vc.play(mixer)`` call, never stopped until leave.
|
|
2267
|
+
"""
|
|
2268
|
+
try:
|
|
2269
|
+
from voice_mixer import VoiceMixer
|
|
2270
|
+
except ImportError:
|
|
2271
|
+
from .voice_mixer import VoiceMixer
|
|
2272
|
+
|
|
2273
|
+
mixer = VoiceMixer(
|
|
2274
|
+
ambient_gain=float(self._voice_fx_cfg.get("ambient_gain", 0.18)),
|
|
2275
|
+
duck_gain=float(self._voice_fx_cfg.get("duck_gain", 0.06)),
|
|
2276
|
+
speech_gain=float(self._voice_fx_cfg.get("speech_gain", 1.0)),
|
|
2277
|
+
)
|
|
2278
|
+
ambient = await asyncio.to_thread(self._get_ambient_pcm)
|
|
2279
|
+
if ambient:
|
|
2280
|
+
mixer.set_ambient(ambient)
|
|
2281
|
+
|
|
2282
|
+
def _after(error):
|
|
2283
|
+
if error:
|
|
2284
|
+
logger.error("Voice mixer stream error (guild=%d): %s", guild_id, error)
|
|
2285
|
+
|
|
2286
|
+
if vc.is_playing():
|
|
2287
|
+
vc.stop()
|
|
2288
|
+
vc.play(mixer, after=_after)
|
|
2289
|
+
self._voice_mixers[guild_id] = mixer
|
|
2290
|
+
logger.info("Voice mixer installed (guild=%d, ambient=%s)", guild_id, bool(ambient))
|
|
2291
|
+
|
|
2292
|
+
async def play_ack_in_voice(self, guild_id: int, phrase: Optional[str] = None) -> bool:
|
|
2293
|
+
"""Speak a short acknowledgement over the ambient bed.
|
|
2294
|
+
|
|
2295
|
+
Called from the gateway's tool-progress hook on the first tool call of
|
|
2296
|
+
a turn, so the user hears "let me look into that" before the bot goes
|
|
2297
|
+
quiet to work. No-op unless the mixer is installed and acks enabled.
|
|
2298
|
+
"""
|
|
2299
|
+
if not self._voice_fx_cfg.get("ack_enabled"):
|
|
2300
|
+
return False
|
|
2301
|
+
mixer = self._voice_mixers.get(guild_id)
|
|
2302
|
+
if mixer is None:
|
|
2303
|
+
return False
|
|
2304
|
+
if phrase is None:
|
|
2305
|
+
import random
|
|
2306
|
+
phrases = self._voice_fx_cfg.get("ack_phrases") or ["One moment."]
|
|
2307
|
+
phrase = random.choice(phrases)
|
|
2308
|
+
|
|
2309
|
+
# Synthesise the ack via the configured TTS provider, then layer it.
|
|
2310
|
+
import uuid as _uuid
|
|
2311
|
+
audio_path = os.path.join(
|
|
2312
|
+
tempfile.gettempdir(), "hermes_voice",
|
|
2313
|
+
f"ack_{_uuid.uuid4().hex[:12]}.mp3",
|
|
2314
|
+
)
|
|
2315
|
+
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
|
|
2316
|
+
try:
|
|
2317
|
+
from tools.tts_tool import text_to_speech_tool
|
|
2318
|
+
result_json = await asyncio.to_thread(
|
|
2319
|
+
text_to_speech_tool, text=phrase, output_path=audio_path
|
|
2320
|
+
)
|
|
2321
|
+
result = json.loads(result_json)
|
|
2322
|
+
actual = result.get("file_path", audio_path)
|
|
2323
|
+
if not result.get("success") or not os.path.isfile(actual):
|
|
2324
|
+
return False
|
|
2325
|
+
try:
|
|
2326
|
+
from voice_mixer import decode_to_pcm
|
|
2327
|
+
except ImportError:
|
|
2328
|
+
from .voice_mixer import decode_to_pcm
|
|
2329
|
+
pcm = await asyncio.to_thread(decode_to_pcm, actual)
|
|
2330
|
+
if not pcm:
|
|
2331
|
+
return False
|
|
2332
|
+
mixer.play_speech(
|
|
2333
|
+
pcm, gain=float(self._voice_fx_cfg.get("speech_gain", 1.0))
|
|
2334
|
+
)
|
|
2335
|
+
self._reset_voice_timeout(guild_id)
|
|
2336
|
+
return True
|
|
2337
|
+
except Exception as e:
|
|
2338
|
+
logger.debug("play_ack_in_voice failed: %s", e)
|
|
2339
|
+
return False
|
|
2340
|
+
finally:
|
|
2341
|
+
for p in {audio_path, locals().get("actual")}:
|
|
2342
|
+
if p and os.path.isfile(p):
|
|
2343
|
+
try:
|
|
2344
|
+
os.unlink(p)
|
|
2345
|
+
except OSError:
|
|
2346
|
+
pass
|
|
2347
|
+
|
|
2348
|
+
def voice_mixer_active(self, guild_id: int) -> bool:
|
|
2349
|
+
"""True when a continuous mixer is installed for this guild."""
|
|
2350
|
+
mixers = getattr(self, "_voice_mixers", None)
|
|
2351
|
+
return bool(mixers) and mixers.get(guild_id) is not None
|
|
2352
|
+
|
|
1928
2353
|
async def join_voice_channel(self, channel) -> bool:
|
|
1929
2354
|
"""Join a Discord voice channel. Returns True on success."""
|
|
1930
2355
|
if not self._client or not DISCORD_AVAILABLE:
|
|
@@ -1957,6 +2382,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
1957
2382
|
except Exception as e:
|
|
1958
2383
|
logger.warning("Voice receiver failed to start: %s", e)
|
|
1959
2384
|
|
|
2385
|
+
# Phase 3: install the continuous mixer (ambient bed + ducked
|
|
2386
|
+
# speech). Best-effort — if it fails we fall back to the legacy
|
|
2387
|
+
# one-shot FFmpegPCMAudio playback path in play_in_voice_channel.
|
|
2388
|
+
if getattr(self, "_voice_fx_cfg", {}).get("enabled"):
|
|
2389
|
+
try:
|
|
2390
|
+
await self._install_voice_mixer(guild_id, vc)
|
|
2391
|
+
except Exception as e:
|
|
2392
|
+
logger.warning("Voice mixer failed to start: %s", e)
|
|
2393
|
+
|
|
1960
2394
|
return True
|
|
1961
2395
|
|
|
1962
2396
|
async def leave_voice_channel(self, guild_id: int) -> None:
|
|
@@ -1970,8 +2404,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
1970
2404
|
if listen_task:
|
|
1971
2405
|
listen_task.cancel()
|
|
1972
2406
|
|
|
2407
|
+
# Tear down the mixer (stops the continuous outgoing stream).
|
|
2408
|
+
if getattr(self, "_voice_mixers", None) is not None:
|
|
2409
|
+
self._voice_mixers.pop(guild_id, None)
|
|
2410
|
+
|
|
1973
2411
|
vc = self._voice_clients.pop(guild_id, None)
|
|
1974
2412
|
if vc and vc.is_connected():
|
|
2413
|
+
try:
|
|
2414
|
+
if vc.is_playing():
|
|
2415
|
+
vc.stop()
|
|
2416
|
+
except Exception:
|
|
2417
|
+
pass
|
|
1975
2418
|
await vc.disconnect()
|
|
1976
2419
|
task = self._voice_timeout_tasks.pop(guild_id, None)
|
|
1977
2420
|
if task:
|
|
@@ -1983,11 +2426,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
1983
2426
|
PLAYBACK_TIMEOUT = 120
|
|
1984
2427
|
|
|
1985
2428
|
async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
|
|
1986
|
-
"""Play an audio file in the connected voice channel.
|
|
2429
|
+
"""Play an audio file in the connected voice channel.
|
|
2430
|
+
|
|
2431
|
+
When the continuous mixer is installed for this guild, the clip is
|
|
2432
|
+
decoded to PCM and layered over the ambient bed (ducking it) so the
|
|
2433
|
+
reply can overlap the idle "thinking" loop seamlessly. Otherwise we
|
|
2434
|
+
fall back to the legacy one-shot FFmpegPCMAudio path.
|
|
2435
|
+
"""
|
|
1987
2436
|
vc = self._voice_clients.get(guild_id)
|
|
1988
2437
|
if not vc or not vc.is_connected():
|
|
1989
2438
|
return False
|
|
1990
2439
|
|
|
2440
|
+
# ── Mixer path (overlap + ducking) ──────────────────────────────
|
|
2441
|
+
mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None
|
|
2442
|
+
if mixer is not None:
|
|
2443
|
+
try:
|
|
2444
|
+
from voice_mixer import decode_to_pcm
|
|
2445
|
+
except ImportError:
|
|
2446
|
+
from .voice_mixer import decode_to_pcm
|
|
2447
|
+
pcm = await asyncio.to_thread(decode_to_pcm, audio_path)
|
|
2448
|
+
if pcm:
|
|
2449
|
+
speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0))
|
|
2450
|
+
mixer.play_speech(pcm, gain=speech_gain)
|
|
2451
|
+
# Block until the speech child drains so callers serialise
|
|
2452
|
+
# replies (mirrors legacy semantics) but the ambient keeps
|
|
2453
|
+
# playing underneath the whole time.
|
|
2454
|
+
wait_start = time.monotonic()
|
|
2455
|
+
while mixer.speech_active:
|
|
2456
|
+
if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:
|
|
2457
|
+
logger.warning("Mixer speech playback timed out after %ds", self.PLAYBACK_TIMEOUT)
|
|
2458
|
+
mixer.stop_speech()
|
|
2459
|
+
break
|
|
2460
|
+
await asyncio.sleep(0.05)
|
|
2461
|
+
self._reset_voice_timeout(guild_id)
|
|
2462
|
+
return True
|
|
2463
|
+
logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path)
|
|
2464
|
+
|
|
2465
|
+
# ── Legacy one-shot path (no mixer) ─────────────────────────────
|
|
1991
2466
|
# Pause voice receiver while playing (echo prevention)
|
|
1992
2467
|
receiver = self._voice_receivers.get(guild_id)
|
|
1993
2468
|
if receiver:
|
|
@@ -2053,6 +2528,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
2053
2528
|
except asyncio.CancelledError:
|
|
2054
2529
|
return
|
|
2055
2530
|
text_ch_id = self._voice_text_channels.get(guild_id)
|
|
2531
|
+
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
|
|
2532
|
+
# the channel (leaving is ``/voice leave``). The inactivity timer only
|
|
2533
|
+
# counts the bot's OWN audio as activity, so under voice-off mode it
|
|
2534
|
+
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
|
|
2535
|
+
# text channel with "Left voice channel (inactivity timeout)." Honor the
|
|
2536
|
+
# user's choice: skip the auto-disconnect while voice replies are off.
|
|
2537
|
+
# (The timer re-arms when the bot next speaks or hears a user.)
|
|
2538
|
+
_mode_getter = getattr(self, "_voice_mode_getter", None)
|
|
2539
|
+
if text_ch_id is not None and _mode_getter is not None:
|
|
2540
|
+
try:
|
|
2541
|
+
if _mode_getter(str(text_ch_id)) == "off":
|
|
2542
|
+
return
|
|
2543
|
+
except Exception:
|
|
2544
|
+
pass
|
|
2056
2545
|
await self.leave_voice_channel(guild_id)
|
|
2057
2546
|
# Notify the runner so it can clean up voice_mode state
|
|
2058
2547
|
if self._on_voice_disconnect and text_ch_id:
|
|
@@ -2183,6 +2672,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
2183
2672
|
is_dm=False,
|
|
2184
2673
|
):
|
|
2185
2674
|
continue
|
|
2675
|
+
# A user speaking to the bot is activity too — not just the
|
|
2676
|
+
# bot's own playback. Reset the inactivity timer so an active
|
|
2677
|
+
# listener isn't disconnected mid-conversation (this also
|
|
2678
|
+
# covers voice-on text-only sessions that never play audio).
|
|
2679
|
+
self._reset_voice_timeout(guild_id)
|
|
2186
2680
|
await self._process_voice_input(guild_id, user_id, pcm_data)
|
|
2187
2681
|
except asyncio.CancelledError:
|
|
2188
2682
|
pass
|
|
@@ -3149,6 +3643,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3149
3643
|
)
|
|
3150
3644
|
|
|
3151
3645
|
already_registered: set[str] = set()
|
|
3646
|
+
# Native commands above are registered first and are the highest
|
|
3647
|
+
# priority, so they always survive the 100-command cap. Reserve one
|
|
3648
|
+
# slot for the consolidated ``/skill`` group registered further below.
|
|
3649
|
+
slot_cap = _DISCORD_MAX_APP_COMMANDS - 1
|
|
3650
|
+
dropped_over_cap = 0
|
|
3152
3651
|
try:
|
|
3153
3652
|
from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates
|
|
3154
3653
|
|
|
@@ -3166,6 +3665,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3166
3665
|
discord_name = cmd_def.name.lower()[:32]
|
|
3167
3666
|
if discord_name in already_registered:
|
|
3168
3667
|
continue
|
|
3668
|
+
if len(already_registered) >= slot_cap:
|
|
3669
|
+
dropped_over_cap += 1
|
|
3670
|
+
continue
|
|
3169
3671
|
auto_cmd = _build_auto_slash_command(
|
|
3170
3672
|
cmd_def.name,
|
|
3171
3673
|
cmd_def.description,
|
|
@@ -3198,6 +3700,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3198
3700
|
discord_name = plugin_name.lower()[:32]
|
|
3199
3701
|
if discord_name in already_registered:
|
|
3200
3702
|
continue
|
|
3703
|
+
if len(already_registered) >= slot_cap:
|
|
3704
|
+
dropped_over_cap += 1
|
|
3705
|
+
continue
|
|
3201
3706
|
auto_cmd = _build_auto_slash_command(
|
|
3202
3707
|
plugin_name,
|
|
3203
3708
|
plugin_desc,
|
|
@@ -3220,6 +3725,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3220
3725
|
# supporting up to 25 categories × 25 skills = 625 skills.
|
|
3221
3726
|
self._register_skill_group(tree)
|
|
3222
3727
|
|
|
3728
|
+
if dropped_over_cap:
|
|
3729
|
+
# Staying under the cap keeps the whole sync succeeding; without
|
|
3730
|
+
# this guard a single over-limit command makes Discord reject the
|
|
3731
|
+
# entire batch (error 30032), breaking every slash command.
|
|
3732
|
+
logger.warning(
|
|
3733
|
+
"[%s] Reached Discord's limit of %d slash commands; skipped %d "
|
|
3734
|
+
"lower-priority command(s) to keep the command sync working. "
|
|
3735
|
+
"Disable slash commands you don't need or trim installed plugins "
|
|
3736
|
+
"to surface them all.",
|
|
3737
|
+
self.name,
|
|
3738
|
+
_DISCORD_MAX_APP_COMMANDS,
|
|
3739
|
+
dropped_over_cap,
|
|
3740
|
+
)
|
|
3741
|
+
|
|
3223
3742
|
# Optional defense-in-depth: hide every slash command from non-admin
|
|
3224
3743
|
# guild members in Discord's slash picker. Server-side authorization
|
|
3225
3744
|
# (``_check_slash_authorization``) is the actual gate; this is purely
|
|
@@ -3749,6 +4268,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3749
4268
|
self,
|
|
3750
4269
|
channel: Any,
|
|
3751
4270
|
before: "DiscordMessage",
|
|
4271
|
+
reply_target: Optional[Any] = None,
|
|
3752
4272
|
) -> str:
|
|
3753
4273
|
"""Fetch recent channel messages for conversational context.
|
|
3754
4274
|
|
|
@@ -3756,6 +4276,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3756
4276
|
a message sent by this bot (the natural partition point between
|
|
3757
4277
|
bot turns) or reaches ``history_backfill_limit``.
|
|
3758
4278
|
|
|
4279
|
+
When ``reply_target`` is provided (the user replied to a specific
|
|
4280
|
+
message), a second backward scan is run ending at that target so the
|
|
4281
|
+
agent sees the conversation surrounding what the user pointed at —
|
|
4282
|
+
even when the reply target sits *before* the most recent bot turn and
|
|
4283
|
+
would otherwise be cut off by the self-message partition. The two
|
|
4284
|
+
windows are merged chronologically and de-duplicated by message ID.
|
|
4285
|
+
|
|
3759
4286
|
Returns a formatted block like::
|
|
3760
4287
|
|
|
3761
4288
|
[Recent channel messages]
|
|
@@ -3789,7 +4316,47 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3789
4316
|
pass # Malformed cache entry — fall back to cold-start scan
|
|
3790
4317
|
|
|
3791
4318
|
try:
|
|
3792
|
-
|
|
4319
|
+
def _keep(msg) -> Optional[str]:
|
|
4320
|
+
"""Return a formatted ``[name] content`` line, or None to skip.
|
|
4321
|
+
|
|
4322
|
+
Encapsulates the system-message / non-conversational / other-bot
|
|
4323
|
+
filtering so both the primary and reply-anchored scans apply
|
|
4324
|
+
identical rules. Does NOT enforce the self-message partition —
|
|
4325
|
+
callers decide where to stop.
|
|
4326
|
+
"""
|
|
4327
|
+
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
|
|
4328
|
+
return None
|
|
4329
|
+
content = getattr(msg, "clean_content", msg.content) or ""
|
|
4330
|
+
if (
|
|
4331
|
+
str(getattr(msg, "id", "")) in self._nonconversational_messages
|
|
4332
|
+
or _looks_like_nonconversational_history_message(content)
|
|
4333
|
+
):
|
|
4334
|
+
return None
|
|
4335
|
+
# Respect DISCORD_ALLOW_BOTS for other bots. For history
|
|
4336
|
+
# context, "mentions" is treated as "all" — we are deciding
|
|
4337
|
+
# what context to show, not whether to respond.
|
|
4338
|
+
if (
|
|
4339
|
+
getattr(msg.author, "bot", False)
|
|
4340
|
+
and msg.author != self._client.user
|
|
4341
|
+
and not include_other_bots
|
|
4342
|
+
):
|
|
4343
|
+
return None
|
|
4344
|
+
if not content and msg.attachments:
|
|
4345
|
+
content = "(attachment)"
|
|
4346
|
+
if not content:
|
|
4347
|
+
return None
|
|
4348
|
+
name = (
|
|
4349
|
+
getattr(msg.author, "display_name", None)
|
|
4350
|
+
or getattr(msg.author, "name", None)
|
|
4351
|
+
or "unknown"
|
|
4352
|
+
)
|
|
4353
|
+
if getattr(msg.author, "bot", False):
|
|
4354
|
+
name = f"{name} [bot]"
|
|
4355
|
+
return f"[{name}] {content}"
|
|
4356
|
+
|
|
4357
|
+
# ── Primary window: recent channel activity since the last bot turn ──
|
|
4358
|
+
collected: List[Tuple[str, str]] = [] # (message_id, line)
|
|
4359
|
+
seen_ids: set = set()
|
|
3793
4360
|
# IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x
|
|
3794
4361
|
# silently flips the default to True when `after=` is supplied,
|
|
3795
4362
|
# which would select the *earliest* N messages after our last
|
|
@@ -3803,39 +4370,89 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
3803
4370
|
after=_after_obj,
|
|
3804
4371
|
oldest_first=False,
|
|
3805
4372
|
):
|
|
3806
|
-
#
|
|
3807
|
-
#
|
|
3808
|
-
#
|
|
4373
|
+
# Non-conversational lifecycle/status bumps (self-improvement
|
|
4374
|
+
# reviews, background-process notices, restart banners) must be
|
|
4375
|
+
# skipped BEFORE the partition check — otherwise a delayed
|
|
4376
|
+
# status bump authored by us would be mistaken for the real
|
|
4377
|
+
# last bot turn and hide messages that came after it.
|
|
4378
|
+
_content = getattr(msg, "clean_content", msg.content) or ""
|
|
4379
|
+
if (
|
|
4380
|
+
str(getattr(msg, "id", "")) in self._nonconversational_messages
|
|
4381
|
+
or _looks_like_nonconversational_history_message(_content)
|
|
4382
|
+
):
|
|
4383
|
+
continue
|
|
4384
|
+
# Stop at our own (conversational) message — this is the
|
|
4385
|
+
# partition point. Everything before this is already in the
|
|
4386
|
+
# session transcript. (Redundant when _after_obj is set, but
|
|
4387
|
+
# needed for cold start.)
|
|
3809
4388
|
if msg.author == self._client.user:
|
|
3810
4389
|
break
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
|
|
3814
|
-
continue
|
|
3815
|
-
|
|
3816
|
-
# Respect DISCORD_ALLOW_BOTS for other bots.
|
|
3817
|
-
# For history context, "mentions" is treated as "all" — we are
|
|
3818
|
-
# deciding what context to show, not whether to respond.
|
|
3819
|
-
if getattr(msg.author, "bot", False) and not include_other_bots:
|
|
3820
|
-
continue
|
|
3821
|
-
|
|
3822
|
-
content = getattr(msg, "clean_content", msg.content) or ""
|
|
3823
|
-
if not content and msg.attachments:
|
|
3824
|
-
content = "(attachment)"
|
|
3825
|
-
if not content:
|
|
4390
|
+
line = _keep(msg)
|
|
4391
|
+
if line is None:
|
|
3826
4392
|
continue
|
|
4393
|
+
mid = str(getattr(msg, "id", ""))
|
|
4394
|
+
collected.append((mid, line))
|
|
4395
|
+
if mid:
|
|
4396
|
+
seen_ids.add(mid)
|
|
4397
|
+
|
|
4398
|
+
# ── Reply window: context around the message the user pointed at ──
|
|
4399
|
+
# When the user replied to a specific message that sits BEFORE the
|
|
4400
|
+
# primary window's partition point, the surrounding exchange isn't
|
|
4401
|
+
# captured above. Fetch a small window ending just after the reply
|
|
4402
|
+
# target so the agent sees what it was referencing. This window is
|
|
4403
|
+
# NOT partitioned on the self-message boundary — the whole point is
|
|
4404
|
+
# to surface older context the transcript lacks.
|
|
4405
|
+
reply_collected: List[Tuple[str, str]] = []
|
|
4406
|
+
reply_target_id = str(getattr(reply_target, "id", "")) if reply_target else ""
|
|
4407
|
+
if reply_target is not None and reply_target_id and reply_target_id not in seen_ids:
|
|
4408
|
+
# Reuse the same cap as the primary scan but keep the reply
|
|
4409
|
+
# window modest — it's anchored context, not a full backfill.
|
|
4410
|
+
reply_limit = max(1, min(limit, 10))
|
|
4411
|
+
# `before` is exclusive in discord.py, so to *include* the
|
|
4412
|
+
# target we anchor at target_id + 1. Use a minimal snowflake
|
|
4413
|
+
# shim (any object exposing ``.id`` satisfies discord.py's
|
|
4414
|
+
# Snowflake protocol) rather than discord.Object, so this path
|
|
4415
|
+
# works under test doubles that stub the discord module too.
|
|
4416
|
+
try:
|
|
4417
|
+
_before_obj = _Snowflake(int(reply_target_id) + 1)
|
|
4418
|
+
except (ValueError, TypeError):
|
|
4419
|
+
_before_obj = before
|
|
4420
|
+
async for msg in channel.history(
|
|
4421
|
+
limit=reply_limit,
|
|
4422
|
+
before=_before_obj,
|
|
4423
|
+
oldest_first=False,
|
|
4424
|
+
):
|
|
4425
|
+
line = _keep(msg)
|
|
4426
|
+
if line is None:
|
|
4427
|
+
continue
|
|
4428
|
+
mid = str(getattr(msg, "id", ""))
|
|
4429
|
+
if mid and mid in seen_ids:
|
|
4430
|
+
continue
|
|
4431
|
+
reply_collected.append((mid, line))
|
|
4432
|
+
if mid:
|
|
4433
|
+
seen_ids.add(mid)
|
|
3827
4434
|
|
|
3828
|
-
|
|
3829
|
-
if getattr(msg.author, "bot", False):
|
|
3830
|
-
name = f"{name} [bot]"
|
|
3831
|
-
collected.append(f"[{name}] {content}")
|
|
3832
|
-
|
|
3833
|
-
if not collected:
|
|
4435
|
+
if not collected and not reply_collected:
|
|
3834
4436
|
return ""
|
|
3835
4437
|
|
|
3836
|
-
# channel.history returns newest-first
|
|
4438
|
+
# channel.history returns newest-first; reverse each window for
|
|
4439
|
+
# chronological order, then present reply context first (it is
|
|
4440
|
+
# older) followed by the recent activity.
|
|
3837
4441
|
collected.reverse()
|
|
3838
|
-
|
|
4442
|
+
reply_collected.reverse()
|
|
4443
|
+
|
|
4444
|
+
blocks: List[str] = []
|
|
4445
|
+
if reply_collected:
|
|
4446
|
+
blocks.append(
|
|
4447
|
+
"[Context around the replied-to message]\n"
|
|
4448
|
+
+ "\n".join(line for _id, line in reply_collected)
|
|
4449
|
+
)
|
|
4450
|
+
if collected:
|
|
4451
|
+
blocks.append(
|
|
4452
|
+
"[Recent channel messages]\n"
|
|
4453
|
+
+ "\n".join(line for _id, line in collected)
|
|
4454
|
+
)
|
|
4455
|
+
return "\n\n".join(blocks)
|
|
3839
4456
|
|
|
3840
4457
|
except discord.Forbidden:
|
|
3841
4458
|
logger.debug("[%s] Missing permissions to fetch channel history", self.name)
|
|
@@ -4101,6 +4718,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4101
4718
|
)
|
|
4102
4719
|
|
|
4103
4720
|
msg = await channel.send(embed=embed, view=view)
|
|
4721
|
+
view._message = msg # store for on_timeout expiration editing
|
|
4104
4722
|
return SendResult(success=True, message_id=str(msg.id))
|
|
4105
4723
|
|
|
4106
4724
|
except Exception as e:
|
|
@@ -4140,6 +4758,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4140
4758
|
)
|
|
4141
4759
|
|
|
4142
4760
|
msg = await channel.send(embed=embed, view=view)
|
|
4761
|
+
view._message = msg # store for on_timeout expiration editing
|
|
4143
4762
|
return SendResult(success=True, message_id=str(msg.id))
|
|
4144
4763
|
except Exception as e:
|
|
4145
4764
|
return SendResult(success=False, error=str(e))
|
|
@@ -4164,6 +4783,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4164
4783
|
Open-ended mode (``choices`` empty/None): renders the question as
|
|
4165
4784
|
plain embed text — no buttons. The gateway's text-intercept captures
|
|
4166
4785
|
the next message in this session and resolves the clarify.
|
|
4786
|
+
|
|
4787
|
+
Choice normalisation: ``choices`` may contain bare strings OR dicts
|
|
4788
|
+
(LLMs sometimes emit ``[{"description": "..."}]`` instead of bare
|
|
4789
|
+
strings, which would otherwise render as raw Python repr on the
|
|
4790
|
+
button label). Dict choices are unwrapped against the canonical
|
|
4791
|
+
LLM tool-call keys ``label``, ``description``, ``text``, ``title``
|
|
4792
|
+
in that order. Dicts with none of those keys are dropped.
|
|
4167
4793
|
"""
|
|
4168
4794
|
if not self._client or not DISCORD_AVAILABLE:
|
|
4169
4795
|
return SendResult(success=False, error="Not connected")
|
|
@@ -4189,8 +4815,37 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4189
4815
|
color=discord.Color.orange(),
|
|
4190
4816
|
)
|
|
4191
4817
|
|
|
4818
|
+
# Normalise choices: LLMs sometimes emit `[{"description": "..."}]`
|
|
4819
|
+
# instead of bare strings, which would render as raw Python repr on
|
|
4820
|
+
# the button label. Unwrap the common shapes, then stringify.
|
|
4821
|
+
def _flatten_choice(c):
|
|
4822
|
+
if c is None:
|
|
4823
|
+
return ""
|
|
4824
|
+
if isinstance(c, str):
|
|
4825
|
+
return c.strip()
|
|
4826
|
+
if isinstance(c, dict):
|
|
4827
|
+
# Prefer the canonical LLM tool-call user-facing keys
|
|
4828
|
+
# in the order the LLM is most likely to emit them.
|
|
4829
|
+
# 'name' and 'value' are deliberately NOT here: they're
|
|
4830
|
+
# Discord-component-shaped fields that could appear in
|
|
4831
|
+
# dicts that aren't meant to be choices (e.g., a
|
|
4832
|
+
# developer-error wiring that passes a Button-shaped
|
|
4833
|
+
# object). Picking them would leak raw enum values
|
|
4834
|
+
# or 4-char model identifiers onto user-facing buttons.
|
|
4835
|
+
# If a dict has none of the canonical keys, drop it
|
|
4836
|
+
# rather than picking some random field — a garbage
|
|
4837
|
+
# button label is worse than no button at all.
|
|
4838
|
+
for key in ("label", "description", "text", "title"):
|
|
4839
|
+
v = c.get(key)
|
|
4840
|
+
if isinstance(v, str) and v.strip():
|
|
4841
|
+
return v.strip()
|
|
4842
|
+
return ""
|
|
4843
|
+
if isinstance(c, (list, tuple)):
|
|
4844
|
+
return " ".join(_flatten_choice(x) for x in c).strip()
|
|
4845
|
+
return str(c).strip()
|
|
4846
|
+
|
|
4192
4847
|
clean_choices = [
|
|
4193
|
-
|
|
4848
|
+
s for s in (_flatten_choice(c) for c in (choices or [])) if s
|
|
4194
4849
|
]
|
|
4195
4850
|
# Discord allows up to 5 buttons per row, 5 rows per view = 25.
|
|
4196
4851
|
# We reserve one slot for the "Other" button, so cap at 24 choices.
|
|
@@ -4217,6 +4872,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4217
4872
|
view = None
|
|
4218
4873
|
|
|
4219
4874
|
msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed)
|
|
4875
|
+
if view:
|
|
4876
|
+
view._message = msg # store for on_timeout expiration editing
|
|
4220
4877
|
return SendResult(success=True, message_id=str(msg.id))
|
|
4221
4878
|
except Exception as e:
|
|
4222
4879
|
logger.warning("[%s] send_clarify failed: %s", self.name, e)
|
|
@@ -4252,6 +4909,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4252
4909
|
allowed_role_ids=self._allowed_role_ids,
|
|
4253
4910
|
)
|
|
4254
4911
|
msg = await channel.send(embed=embed, view=view)
|
|
4912
|
+
view._message = msg # store for on_timeout expiration editing
|
|
4913
|
+
if _metadata_marks_nonconversational(metadata):
|
|
4914
|
+
self._nonconversational_messages.mark_many([str(msg.id)])
|
|
4255
4915
|
return SendResult(success=True, message_id=str(msg.id))
|
|
4256
4916
|
except Exception as e:
|
|
4257
4917
|
return SendResult(success=False, error=str(e))
|
|
@@ -4311,6 +4971,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4311
4971
|
)
|
|
4312
4972
|
|
|
4313
4973
|
msg = await channel.send(embed=embed, view=view)
|
|
4974
|
+
view._message = msg # store for on_timeout expiration editing
|
|
4314
4975
|
return SendResult(success=True, message_id=str(msg.id))
|
|
4315
4976
|
|
|
4316
4977
|
except Exception as e:
|
|
@@ -4484,7 +5145,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4484
5145
|
raise Exception(f"HTTP {resp.status}")
|
|
4485
5146
|
return await resp.read()
|
|
4486
5147
|
|
|
4487
|
-
async def _handle_message(self, message: DiscordMessage) -> None:
|
|
5148
|
+
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
|
|
4488
5149
|
"""Handle incoming Discord messages."""
|
|
4489
5150
|
# In server channels (not DMs), require the bot to be @mentioned
|
|
4490
5151
|
# UNLESS the channel is in the free-response list or the message is
|
|
@@ -4598,7 +5259,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4598
5259
|
auto_threaded_channel = thread
|
|
4599
5260
|
self._threads.mark(thread_id)
|
|
4600
5261
|
|
|
4601
|
-
|
|
5262
|
+
referenced_attachments = []
|
|
5263
|
+
reference = getattr(message, "reference", None)
|
|
5264
|
+
resolved_reference = getattr(reference, "resolved", None) if reference else None
|
|
5265
|
+
if resolved_reference is not None:
|
|
5266
|
+
referenced_attachments = list(getattr(resolved_reference, "attachments", []) or [])
|
|
5267
|
+
|
|
5268
|
+
all_attachments = list(message.attachments) + snapshot_attachments + referenced_attachments
|
|
4602
5269
|
|
|
4603
5270
|
# Determine message type
|
|
4604
5271
|
msg_type = MessageType.TEXT
|
|
@@ -4668,6 +5335,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4668
5335
|
guild_id=str(guild.id) if guild else None,
|
|
4669
5336
|
parent_chat_id=parent_channel_id,
|
|
4670
5337
|
message_id=str(message.id),
|
|
5338
|
+
role_authorized=role_authorized,
|
|
4671
5339
|
)
|
|
4672
5340
|
|
|
4673
5341
|
# Build media URLs -- download image attachments to local cache so the
|
|
@@ -4818,14 +5486,40 @@ class DiscordAdapter(BasePlatformAdapter):
|
|
|
4818
5486
|
# - any thread (in_bot_thread bypasses the mention check, but
|
|
4819
5487
|
# processing-window gaps and post-restart context still need
|
|
4820
5488
|
# recovery)
|
|
5489
|
+
# - any reply (the user pointed at a specific message; hydrate
|
|
5490
|
+
# the context around it even in a free-response channel where
|
|
5491
|
+
# no mention gap exists — otherwise replies get only the short
|
|
5492
|
+
# "[Replying to: ...]" snippet with no surrounding context)
|
|
4821
5493
|
# DMs skip entirely because every DM message triggers the bot,
|
|
4822
5494
|
# so the session transcript already has everything.
|
|
4823
5495
|
# Auto-threaded messages also skip — we just created the thread,
|
|
4824
5496
|
# there's nothing prior to backfill.
|
|
4825
5497
|
_has_mention_gap = require_mention and not is_free_channel and not in_bot_thread
|
|
4826
|
-
|
|
5498
|
+
_is_reply = message.reference is not None
|
|
5499
|
+
|
|
5500
|
+
# Resolve the replied-to message into an object exposing ``.id``.
|
|
5501
|
+
# discord.py may give us a full Message (resolved), a
|
|
5502
|
+
# DeletedReferencedMessage, or nothing. Duck-type on ``.id``
|
|
5503
|
+
# rather than isinstance(discord.Message) — under test doubles the
|
|
5504
|
+
# discord module (and thus discord.Message) can be a mock, which is
|
|
5505
|
+
# not a valid isinstance() second argument. Any object with an int
|
|
5506
|
+
# id works as a scan anchor; otherwise fall back to a bare snowflake
|
|
5507
|
+
# built from the reference's message_id.
|
|
5508
|
+
_reply_target = None
|
|
5509
|
+
if _is_reply:
|
|
5510
|
+
_resolved = getattr(message.reference, "resolved", None)
|
|
5511
|
+
_resolved_id = getattr(_resolved, "id", None) if _resolved is not None else None
|
|
5512
|
+
if _resolved_id is not None:
|
|
5513
|
+
_reply_target = _resolved
|
|
5514
|
+
else:
|
|
5515
|
+
_ref_mid = getattr(message.reference, "message_id", None)
|
|
5516
|
+
if _ref_mid is not None:
|
|
5517
|
+
with suppress(ValueError, TypeError):
|
|
5518
|
+
_reply_target = _Snowflake(int(_ref_mid))
|
|
5519
|
+
|
|
5520
|
+
if (_has_mention_gap or is_thread or _is_reply) and auto_threaded_channel is None:
|
|
4827
5521
|
_backfill_text = await self._fetch_channel_context(
|
|
4828
|
-
message.channel, before=message,
|
|
5522
|
+
message.channel, before=message, reply_target=_reply_target,
|
|
4829
5523
|
)
|
|
4830
5524
|
if _backfill_text:
|
|
4831
5525
|
_channel_context = _backfill_text
|
|
@@ -4972,34 +5666,35 @@ def _component_check_auth(
|
|
|
4972
5666
|
) -> bool:
|
|
4973
5667
|
"""Shared user-or-role OR semantics for component view button clicks.
|
|
4974
5668
|
|
|
4975
|
-
Mirrors
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
UpdatePromptView, ModelPickerView) used to receive only
|
|
4979
|
-
``allowed_user_ids``: in role-only deployments
|
|
4980
|
-
(DISCORD_ALLOWED_ROLES set, DISCORD_ALLOWED_USERS empty) the user
|
|
4981
|
-
set was empty and the legacy "no allowlist = allow everyone" branch
|
|
4982
|
-
let any guild member click the buttons -- approving exec commands,
|
|
4983
|
-
cancelling slash confirmations, switching the model.
|
|
5669
|
+
Mirrors the gateway's external-surface authorization model: component
|
|
5670
|
+
button clicks must be explicitly authorized by a Discord user/role
|
|
5671
|
+
allowlist, a global user allowlist, or an explicit allow-all flag.
|
|
4984
5672
|
|
|
4985
5673
|
Behavior:
|
|
4986
5674
|
|
|
4987
|
-
-
|
|
4988
|
-
|
|
4989
|
-
- user is in user allowlist -> allow
|
|
5675
|
+
- DISCORD_ALLOW_ALL_USERS or GATEWAY_ALLOW_ALL_USERS -> allow
|
|
5676
|
+
- user is in DISCORD_ALLOWED_USERS or GATEWAY_ALLOWED_USERS -> allow
|
|
4990
5677
|
- role allowlist set + user has a role in it -> allow
|
|
4991
5678
|
- role allowlist set + interaction.user has no resolvable
|
|
4992
5679
|
``roles`` attribute (e.g. DM context with a role policy active)
|
|
4993
5680
|
-> reject (fail closed)
|
|
4994
5681
|
- otherwise -> reject
|
|
4995
5682
|
"""
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
has_roles = bool(role_set)
|
|
5000
|
-
if not has_users and not has_roles:
|
|
5683
|
+
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
|
|
5684
|
+
return True
|
|
5685
|
+
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
|
|
5001
5686
|
return True
|
|
5002
5687
|
|
|
5688
|
+
user_set = {str(uid).strip() for uid in (allowed_user_ids or set()) if str(uid).strip()}
|
|
5689
|
+
global_allowed = {
|
|
5690
|
+
uid.strip()
|
|
5691
|
+
for uid in os.getenv("GATEWAY_ALLOWED_USERS", "").split(",")
|
|
5692
|
+
if uid.strip()
|
|
5693
|
+
}
|
|
5694
|
+
user_set.update(global_allowed)
|
|
5695
|
+
role_set = set(allowed_role_ids or set())
|
|
5696
|
+
has_users = bool(user_set)
|
|
5697
|
+
has_roles = bool(role_set)
|
|
5003
5698
|
user = getattr(interaction, "user", None)
|
|
5004
5699
|
if user is None:
|
|
5005
5700
|
return False
|
|
@@ -5009,7 +5704,7 @@ def _component_check_auth(
|
|
|
5009
5704
|
uid = str(user.id)
|
|
5010
5705
|
except AttributeError:
|
|
5011
5706
|
uid = ""
|
|
5012
|
-
if uid and uid in user_set:
|
|
5707
|
+
if "*" in user_set or (uid and uid in user_set):
|
|
5013
5708
|
return True
|
|
5014
5709
|
|
|
5015
5710
|
if has_roles:
|
|
@@ -5141,6 +5836,17 @@ def _define_discord_view_classes() -> None:
|
|
|
5141
5836
|
self.resolved = True
|
|
5142
5837
|
for child in self.children:
|
|
5143
5838
|
child.disabled = True
|
|
5839
|
+
# Visually update the Discord message so buttons appear disabled.
|
|
5840
|
+
msg = getattr(self, '_message', None)
|
|
5841
|
+
if msg:
|
|
5842
|
+
try:
|
|
5843
|
+
embed = msg.embeds[0] if msg.embeds else None
|
|
5844
|
+
if embed:
|
|
5845
|
+
embed.color = discord.Color.greyple()
|
|
5846
|
+
embed.set_footer(text="⏱ Prompt expired — no action taken")
|
|
5847
|
+
await msg.edit(embed=embed, view=self)
|
|
5848
|
+
except Exception:
|
|
5849
|
+
pass # message deleted or too old to edit
|
|
5144
5850
|
|
|
5145
5851
|
class SlashConfirmView(discord.ui.View):
|
|
5146
5852
|
"""Three-button view for generic slash-command confirmations.
|
|
@@ -5245,6 +5951,17 @@ def _define_discord_view_classes() -> None:
|
|
|
5245
5951
|
self.resolved = True
|
|
5246
5952
|
for child in self.children:
|
|
5247
5953
|
child.disabled = True
|
|
5954
|
+
# Visually update the Discord message so buttons appear disabled.
|
|
5955
|
+
msg = getattr(self, '_message', None)
|
|
5956
|
+
if msg:
|
|
5957
|
+
try:
|
|
5958
|
+
embed = msg.embeds[0] if msg.embeds else None
|
|
5959
|
+
if embed:
|
|
5960
|
+
embed.color = discord.Color.greyple()
|
|
5961
|
+
embed.set_footer(text="⏱ Prompt expired — no action taken")
|
|
5962
|
+
await msg.edit(embed=embed, view=self)
|
|
5963
|
+
except Exception:
|
|
5964
|
+
pass
|
|
5248
5965
|
|
|
5249
5966
|
class UpdatePromptView(discord.ui.View):
|
|
5250
5967
|
"""Interactive Yes/No buttons for ``hermes update`` prompts.
|
|
@@ -5330,6 +6047,17 @@ def _define_discord_view_classes() -> None:
|
|
|
5330
6047
|
self.resolved = True
|
|
5331
6048
|
for child in self.children:
|
|
5332
6049
|
child.disabled = True
|
|
6050
|
+
# Visually update the Discord message so buttons appear disabled.
|
|
6051
|
+
msg = getattr(self, '_message', None)
|
|
6052
|
+
if msg:
|
|
6053
|
+
try:
|
|
6054
|
+
embed = msg.embeds[0] if msg.embeds else None
|
|
6055
|
+
if embed:
|
|
6056
|
+
embed.color = discord.Color.greyple()
|
|
6057
|
+
embed.set_footer(text="⏱ Prompt expired — no action taken")
|
|
6058
|
+
await msg.edit(embed=embed, view=self)
|
|
6059
|
+
except Exception:
|
|
6060
|
+
pass
|
|
5333
6061
|
|
|
5334
6062
|
class ModelPickerView(discord.ui.View):
|
|
5335
6063
|
"""Interactive select-menu view for model switching.
|
|
@@ -5359,6 +6087,7 @@ def _define_discord_view_classes() -> None:
|
|
|
5359
6087
|
self.allowed_role_ids = allowed_role_ids or set()
|
|
5360
6088
|
self.resolved = False
|
|
5361
6089
|
self._selected_provider: str = ""
|
|
6090
|
+
self._pending_expensive_model: str = ""
|
|
5362
6091
|
|
|
5363
6092
|
self._build_provider_select()
|
|
5364
6093
|
|
|
@@ -5441,6 +6170,41 @@ def _define_discord_view_classes() -> None:
|
|
|
5441
6170
|
cancel_btn.callback = self._on_cancel
|
|
5442
6171
|
self.add_item(cancel_btn)
|
|
5443
6172
|
|
|
6173
|
+
def _build_expensive_confirm(self, model_id: str):
|
|
6174
|
+
"""Build confirmation buttons for unusually expensive models."""
|
|
6175
|
+
self.clear_items()
|
|
6176
|
+
self._pending_expensive_model = model_id
|
|
6177
|
+
|
|
6178
|
+
confirm_btn = discord.ui.Button(
|
|
6179
|
+
label="Switch anyway",
|
|
6180
|
+
style=discord.ButtonStyle.red,
|
|
6181
|
+
custom_id="model_expensive_confirm",
|
|
6182
|
+
)
|
|
6183
|
+
confirm_btn.callback = self._on_expensive_confirm
|
|
6184
|
+
self.add_item(confirm_btn)
|
|
6185
|
+
|
|
6186
|
+
cancel_btn = discord.ui.Button(
|
|
6187
|
+
label="Cancel",
|
|
6188
|
+
style=discord.ButtonStyle.grey,
|
|
6189
|
+
custom_id="model_expensive_cancel",
|
|
6190
|
+
)
|
|
6191
|
+
cancel_btn.callback = self._on_cancel
|
|
6192
|
+
self.add_item(cancel_btn)
|
|
6193
|
+
|
|
6194
|
+
async def _expensive_warning_for(self, model_id: str):
|
|
6195
|
+
try:
|
|
6196
|
+
from hermes_cli.model_cost_guard import expensive_model_warning
|
|
6197
|
+
|
|
6198
|
+
# Pricing lookup can hit models.dev / a /models endpoint on a
|
|
6199
|
+
# cache miss — keep it off the event loop.
|
|
6200
|
+
return await asyncio.to_thread(
|
|
6201
|
+
expensive_model_warning,
|
|
6202
|
+
model_id,
|
|
6203
|
+
provider=self._selected_provider,
|
|
6204
|
+
)
|
|
6205
|
+
except Exception:
|
|
6206
|
+
return None
|
|
6207
|
+
|
|
5444
6208
|
async def _on_provider_selected(self, interaction: discord.Interaction):
|
|
5445
6209
|
if not self._check_auth(interaction):
|
|
5446
6210
|
await interaction.response.send_message(
|
|
@@ -5470,7 +6234,11 @@ def _define_discord_view_classes() -> None:
|
|
|
5470
6234
|
view=self,
|
|
5471
6235
|
)
|
|
5472
6236
|
|
|
5473
|
-
async def
|
|
6237
|
+
async def _switch_selected_model(
|
|
6238
|
+
self,
|
|
6239
|
+
interaction: discord.Interaction,
|
|
6240
|
+
model_id: str,
|
|
6241
|
+
):
|
|
5474
6242
|
if self.resolved:
|
|
5475
6243
|
await interaction.response.send_message(
|
|
5476
6244
|
"Already resolved~", ephemeral=True
|
|
@@ -5483,7 +6251,6 @@ def _define_discord_view_classes() -> None:
|
|
|
5483
6251
|
return
|
|
5484
6252
|
|
|
5485
6253
|
self.resolved = True
|
|
5486
|
-
model_id = interaction.data["values"][0]
|
|
5487
6254
|
self.clear_items()
|
|
5488
6255
|
await interaction.response.edit_message(
|
|
5489
6256
|
embed=discord.Embed(
|
|
@@ -5512,6 +6279,50 @@ def _define_discord_view_classes() -> None:
|
|
|
5512
6279
|
view=None,
|
|
5513
6280
|
)
|
|
5514
6281
|
|
|
6282
|
+
async def _on_model_selected(self, interaction: discord.Interaction):
|
|
6283
|
+
if self.resolved:
|
|
6284
|
+
await interaction.response.send_message(
|
|
6285
|
+
"Already resolved~", ephemeral=True
|
|
6286
|
+
)
|
|
6287
|
+
return
|
|
6288
|
+
if not self._check_auth(interaction):
|
|
6289
|
+
await interaction.response.send_message(
|
|
6290
|
+
"You're not authorized~", ephemeral=True
|
|
6291
|
+
)
|
|
6292
|
+
return
|
|
6293
|
+
|
|
6294
|
+
model_id = interaction.data["values"][0]
|
|
6295
|
+
warning = await self._expensive_warning_for(model_id)
|
|
6296
|
+
if warning is not None:
|
|
6297
|
+
self._build_expensive_confirm(model_id)
|
|
6298
|
+
await interaction.response.edit_message(
|
|
6299
|
+
embed=discord.Embed(
|
|
6300
|
+
title="⚠ Expensive Model Warning",
|
|
6301
|
+
description=warning.message,
|
|
6302
|
+
color=discord.Color.red(),
|
|
6303
|
+
),
|
|
6304
|
+
view=self,
|
|
6305
|
+
)
|
|
6306
|
+
return
|
|
6307
|
+
|
|
6308
|
+
await self._switch_selected_model(interaction, model_id)
|
|
6309
|
+
|
|
6310
|
+
async def _on_expensive_confirm(self, interaction: discord.Interaction):
|
|
6311
|
+
if not self._check_auth(interaction):
|
|
6312
|
+
await interaction.response.send_message(
|
|
6313
|
+
"You're not authorized~", ephemeral=True
|
|
6314
|
+
)
|
|
6315
|
+
return
|
|
6316
|
+
if not self._pending_expensive_model:
|
|
6317
|
+
await interaction.response.send_message(
|
|
6318
|
+
"Model selection expired.", ephemeral=True
|
|
6319
|
+
)
|
|
6320
|
+
return
|
|
6321
|
+
await self._switch_selected_model(
|
|
6322
|
+
interaction,
|
|
6323
|
+
self._pending_expensive_model,
|
|
6324
|
+
)
|
|
6325
|
+
|
|
5515
6326
|
async def _on_back(self, interaction: discord.Interaction):
|
|
5516
6327
|
if not self._check_auth(interaction):
|
|
5517
6328
|
await interaction.response.send_message(
|
|
@@ -5555,6 +6366,18 @@ def _define_discord_view_classes() -> None:
|
|
|
5555
6366
|
async def on_timeout(self):
|
|
5556
6367
|
self.resolved = True
|
|
5557
6368
|
self.clear_items()
|
|
6369
|
+
# Visually update the Discord message so it appears expired.
|
|
6370
|
+
msg = getattr(self, '_message', None)
|
|
6371
|
+
if msg:
|
|
6372
|
+
try:
|
|
6373
|
+
embed = discord.Embed(
|
|
6374
|
+
title="⚙ Model Configuration",
|
|
6375
|
+
description="⏱ Selection expired — no model change.",
|
|
6376
|
+
color=discord.Color.greyple(),
|
|
6377
|
+
)
|
|
6378
|
+
await msg.edit(embed=embed, view=self)
|
|
6379
|
+
except Exception:
|
|
6380
|
+
pass
|
|
5558
6381
|
|
|
5559
6382
|
|
|
5560
6383
|
class ClarifyChoiceView(discord.ui.View):
|
|
@@ -5587,10 +6410,47 @@ def _define_discord_view_classes() -> None:
|
|
|
5587
6410
|
self.resolved = False
|
|
5588
6411
|
|
|
5589
6412
|
for index, choice in enumerate(self.choices):
|
|
5590
|
-
# Discord button labels are capped at 80 chars.
|
|
5591
|
-
|
|
6413
|
+
# Discord button labels are capped at 80 chars. On mobile the
|
|
6414
|
+
# visible width is much narrower (often <40 chars before it
|
|
6415
|
+
# wraps to 2 lines and the second line gets cut off), so we
|
|
6416
|
+
# cap aggressively and cut at a word boundary when possible
|
|
6417
|
+
# to keep the trailing text readable.
|
|
6418
|
+
#
|
|
6419
|
+
# Cut strategy (most-preferred to least-preferred):
|
|
6420
|
+
# 1. Last space in the trailing half of the budget
|
|
6421
|
+
# (cleanest word boundary)
|
|
6422
|
+
# 2. Last soft boundary in the trailing half of the
|
|
6423
|
+
# budget (hyphen, comma, period, paren)
|
|
6424
|
+
# 3. Hard cut at the budget limit (last resort)
|
|
6425
|
+
prefix = f"{index + 1}. "
|
|
6426
|
+
budget = 80 - len(prefix)
|
|
6427
|
+
if len(choice) <= budget:
|
|
6428
|
+
label_body = choice
|
|
6429
|
+
else:
|
|
6430
|
+
truncated = choice[: budget - 1].rstrip()
|
|
6431
|
+
cut_at = -1
|
|
6432
|
+
# 1. Last space in the trailing half of the budget.
|
|
6433
|
+
space = truncated.rfind(" ")
|
|
6434
|
+
if space >= budget // 2:
|
|
6435
|
+
cut_at = space
|
|
6436
|
+
# 2. Soft boundary — only if no word boundary found.
|
|
6437
|
+
# Find the latest soft boundary in the trailing half
|
|
6438
|
+
# of the budget; that maximizes preserved text length.
|
|
6439
|
+
# Cut AT the soft boundary (inclusive) so the label
|
|
6440
|
+
# ends on the soft char (e.g. "-" or ",") rather than
|
|
6441
|
+
# on the alpha char that followed it.
|
|
6442
|
+
if cut_at < 0:
|
|
6443
|
+
latest_soft = max(
|
|
6444
|
+
(truncated.rfind(s) for s in ("-", ",", ".", ")")),
|
|
6445
|
+
default=-1,
|
|
6446
|
+
)
|
|
6447
|
+
if latest_soft >= budget // 2:
|
|
6448
|
+
cut_at = latest_soft + 1
|
|
6449
|
+
if cut_at > 0:
|
|
6450
|
+
truncated = truncated[:cut_at]
|
|
6451
|
+
label_body = truncated.rstrip() + "…"
|
|
5592
6452
|
button = discord.ui.Button(
|
|
5593
|
-
label=f"{
|
|
6453
|
+
label=f"{prefix}{label_body}",
|
|
5594
6454
|
style=discord.ButtonStyle.primary,
|
|
5595
6455
|
custom_id=f"clarify:{clarify_id}:{index}",
|
|
5596
6456
|
)
|
|
@@ -5740,6 +6600,17 @@ def _define_discord_view_classes() -> None:
|
|
|
5740
6600
|
self.resolved = True
|
|
5741
6601
|
for child in self.children:
|
|
5742
6602
|
child.disabled = True
|
|
6603
|
+
# Visually update the Discord message so buttons appear disabled.
|
|
6604
|
+
msg = getattr(self, '_message', None)
|
|
6605
|
+
if msg:
|
|
6606
|
+
try:
|
|
6607
|
+
embed = msg.embeds[0] if msg.embeds else None
|
|
6608
|
+
if embed:
|
|
6609
|
+
embed.color = discord.Color.greyple()
|
|
6610
|
+
embed.set_footer(text="⏱ Prompt expired — no action taken")
|
|
6611
|
+
await msg.edit(embed=embed, view=self)
|
|
6612
|
+
except Exception:
|
|
6613
|
+
pass
|
|
5743
6614
|
if DISCORD_AVAILABLE:
|
|
5744
6615
|
_define_discord_view_classes()
|
|
5745
6616
|
|