Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a06b997158 |
@@ -231,21 +231,6 @@ VOICE_TOOLS_OPENAI_KEY=
|
||||
# Slack allowed users (comma-separated Slack user IDs)
|
||||
# SLACK_ALLOWED_USERS=
|
||||
|
||||
# =============================================================================
|
||||
# TELEGRAM INTEGRATION
|
||||
# =============================================================================
|
||||
# Telegram Bot Token - From @BotFather (https://t.me/BotFather)
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_ALLOWED_USERS= # Comma-separated user IDs
|
||||
# TELEGRAM_HOME_CHANNEL= # Default chat for cron delivery
|
||||
# TELEGRAM_HOME_CHANNEL_NAME= # Display name for home channel
|
||||
|
||||
# Webhook mode (optional — for cloud deployments like Fly.io/Railway)
|
||||
# Default is long polling. Setting TELEGRAM_WEBHOOK_URL switches to webhook mode.
|
||||
# TELEGRAM_WEBHOOK_URL=https://my-app.fly.dev/telegram
|
||||
# TELEGRAM_WEBHOOK_PORT=8443
|
||||
# TELEGRAM_WEBHOOK_SECRET= # Recommended for production
|
||||
|
||||
# WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair)
|
||||
# WHATSAPP_ENABLED=false
|
||||
# WHATSAPP_ALLOWED_USERS=15551234567
|
||||
|
||||
@@ -19,8 +19,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
|
||||
@@ -12,8 +12,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
@@ -1,249 +0,0 @@
|
||||
# Hermes Agent v0.6.0 (v2026.3.30)
|
||||
|
||||
**Release Date:** March 30, 2026
|
||||
|
||||
> The multi-instance release — Profiles for running isolated agent instances, MCP server mode, Docker container, fallback provider chains, two new messaging platforms (Feishu/Lark and WeCom), Telegram webhook mode, Slack multi-workspace OAuth, 95 PRs and 16 resolved issues in 2 days.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Profiles — Multi-Instance Hermes** — Run multiple isolated Hermes instances from the same installation. Each profile gets its own config, memory, sessions, skills, and gateway service. Create with `hermes profile create`, switch with `hermes -p <name>`, export/import for sharing. Full token-lock isolation prevents two profiles from using the same bot credential. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681))
|
||||
|
||||
- **MCP Server Mode** — Expose Hermes conversations and sessions to any MCP-compatible client (Claude Desktop, Cursor, VS Code, etc.) via `hermes mcp serve`. Browse conversations, read messages, search across sessions, and manage attachments — all through the Model Context Protocol. Supports both stdio and Streamable HTTP transports. ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795))
|
||||
|
||||
- **Docker Container** — Official Dockerfile for running Hermes Agent in a container. Supports both CLI and gateway modes with volume-mounted config. ([#3668](https://github.com/NousResearch/hermes-agent/pull/3668), closes [#850](https://github.com/NousResearch/hermes-agent/issues/850))
|
||||
|
||||
- **Ordered Fallback Provider Chain** — Configure multiple inference providers with automatic failover. When your primary provider returns errors or is unreachable, Hermes automatically tries the next provider in the chain. Configure via `fallback_providers` in config.yaml. ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813), closes [#1734](https://github.com/NousResearch/hermes-agent/issues/1734))
|
||||
|
||||
- **Feishu/Lark Platform Support** — Full gateway adapter for Feishu (飞书) and Lark with event subscriptions, message cards, group chat, image/file attachments, and interactive card callbacks. ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817), closes [#1788](https://github.com/NousResearch/hermes-agent/issues/1788))
|
||||
|
||||
- **WeCom (Enterprise WeChat) Platform Support** — New gateway adapter for WeCom (企业微信) with text/image/voice messages, group chats, and callback verification. ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847))
|
||||
|
||||
- **Slack Multi-Workspace OAuth** — Connect a single Hermes gateway to multiple Slack workspaces via OAuth token file. Each workspace gets its own bot token, resolved dynamically per incoming event. ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903))
|
||||
|
||||
- **Telegram Webhook Mode & Group Controls** — Run the Telegram adapter in webhook mode as an alternative to polling — faster response times and better for production deployments behind a reverse proxy. New group mention gating controls when the bot responds: always, only when @mentioned, or via regex triggers. ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880), [#3870](https://github.com/NousResearch/hermes-agent/pull/3870))
|
||||
|
||||
- **Exa Search Backend** — Add Exa as an alternative web search and content extraction backend alongside Firecrawl and DuckDuckGo. Set `EXA_API_KEY` and configure as preferred backend. ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648))
|
||||
|
||||
- **Skills & Credentials on Remote Backends** — Mount skill directories and credential files into Modal and Docker containers, so remote terminal sessions have access to the same skills and secrets as local execution. ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890), [#3671](https://github.com/NousResearch/hermes-agent/pull/3671), closes [#3665](https://github.com/NousResearch/hermes-agent/issues/3665), [#3433](https://github.com/NousResearch/hermes-agent/issues/3433))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### Provider & Model Support
|
||||
- **Ordered fallback provider chain** — automatic failover across multiple configured providers ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813))
|
||||
- **Fix api_mode on provider switch** — switching providers via `hermes model` now correctly clears stale `api_mode` instead of hardcoding `chat_completions`, fixing 404s for providers with Anthropic-compatible endpoints ([#3726](https://github.com/NousResearch/hermes-agent/pull/3726), [#3857](https://github.com/NousResearch/hermes-agent/pull/3857), closes [#3685](https://github.com/NousResearch/hermes-agent/issues/3685))
|
||||
- **Stop silent OpenRouter fallback** — when no provider is configured, Hermes now raises a clear error instead of silently routing to OpenRouter ([#3807](https://github.com/NousResearch/hermes-agent/pull/3807), [#3862](https://github.com/NousResearch/hermes-agent/pull/3862))
|
||||
- **Gemini 3.1 preview models** — added to OpenRouter and Nous Portal catalogs ([#3803](https://github.com/NousResearch/hermes-agent/pull/3803), closes [#3753](https://github.com/NousResearch/hermes-agent/issues/3753))
|
||||
- **Gemini direct API context length** — full context length resolution for direct Google AI endpoints ([#3876](https://github.com/NousResearch/hermes-agent/pull/3876))
|
||||
- **gpt-5.4-mini** added to Codex fallback catalog ([#3855](https://github.com/NousResearch/hermes-agent/pull/3855))
|
||||
- **Curated model lists preferred** over live API probe when the probe returns fewer models ([#3856](https://github.com/NousResearch/hermes-agent/pull/3856), [#3867](https://github.com/NousResearch/hermes-agent/pull/3867))
|
||||
- **User-friendly 429 rate limit messages** with Retry-After countdown ([#3809](https://github.com/NousResearch/hermes-agent/pull/3809))
|
||||
- **Auxiliary client placeholder key** for local servers without auth requirements ([#3842](https://github.com/NousResearch/hermes-agent/pull/3842))
|
||||
- **INFO-level logging** for auxiliary provider resolution ([#3866](https://github.com/NousResearch/hermes-agent/pull/3866))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **Subagent status reporting** — reports `completed` status when summary exists instead of generic failure ([#3829](https://github.com/NousResearch/hermes-agent/pull/3829))
|
||||
- **Session log file updated during compression** — prevents stale file references after context compression ([#3835](https://github.com/NousResearch/hermes-agent/pull/3835))
|
||||
- **Omit empty tools param** — sends no `tools` parameter when empty instead of `None`, fixing compatibility with strict providers ([#3820](https://github.com/NousResearch/hermes-agent/pull/3820))
|
||||
|
||||
### Profiles & Multi-Instance
|
||||
- **Profiles system** — `hermes profile create/list/switch/delete/export/import/rename`. Each profile gets isolated HERMES_HOME, gateway service, CLI wrapper. Token locks prevent credential collisions. Tab completion for profile names. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681))
|
||||
- **Profile-aware display paths** — all user-facing `~/.hermes` paths replaced with `display_hermes_home()` to show the correct profile directory ([#3623](https://github.com/NousResearch/hermes-agent/pull/3623))
|
||||
- **Lazy display_hermes_home imports** — prevents `ImportError` during `hermes update` when modules cache stale bytecode ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776))
|
||||
- **HERMES_HOME for protected paths** — `.env` write-deny path now respects HERMES_HOME instead of hardcoded `~/.hermes` ([#3840](https://github.com/NousResearch/hermes-agent/pull/3840))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### New Platforms
|
||||
- **Feishu/Lark** — Full adapter with event subscriptions, message cards, group chat, image/file attachments, interactive card callbacks ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817))
|
||||
- **WeCom (Enterprise WeChat)** — Text/image/voice messages, group chats, callback verification ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847))
|
||||
|
||||
### Telegram
|
||||
- **Webhook mode** — run as webhook endpoint instead of polling for production deployments ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880))
|
||||
- **Group mention gating & regex triggers** — configurable bot response behavior in groups: always, @mention-only, or regex-matched ([#3870](https://github.com/NousResearch/hermes-agent/pull/3870))
|
||||
- **Gracefully handle deleted reply targets** — no more crashes when the message being replied to was deleted ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858), closes [#3229](https://github.com/NousResearch/hermes-agent/issues/3229))
|
||||
|
||||
### Discord
|
||||
- **Message processing reactions** — adds a reaction emoji while processing and removes it when done, giving visual feedback in channels ([#3871](https://github.com/NousResearch/hermes-agent/pull/3871))
|
||||
- **DISCORD_IGNORE_NO_MENTION** — skip messages that @mention other users/bots but not Hermes ([#3640](https://github.com/NousResearch/hermes-agent/pull/3640))
|
||||
- **Clean up deferred "thinking..."** — properly removes the "thinking..." indicator after slash commands complete ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674), closes [#3595](https://github.com/NousResearch/hermes-agent/issues/3595))
|
||||
|
||||
### Slack
|
||||
- **Multi-workspace OAuth** — connect to multiple Slack workspaces from a single gateway via OAuth token file ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903))
|
||||
|
||||
### WhatsApp
|
||||
- **Persistent aiohttp session** — reuse HTTP sessions across requests instead of creating new ones per message ([#3818](https://github.com/NousResearch/hermes-agent/pull/3818))
|
||||
- **LID↔phone alias resolution** — correctly match Linked ID and phone number formats in allowlists ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830))
|
||||
- **Skip reply prefix in bot mode** — cleaner message formatting when running as a WhatsApp bot ([#3931](https://github.com/NousResearch/hermes-agent/pull/3931))
|
||||
|
||||
### Matrix
|
||||
- **Native voice messages via MSC3245** — send voice messages as proper Matrix voice events instead of file attachments ([#3877](https://github.com/NousResearch/hermes-agent/pull/3877))
|
||||
|
||||
### Mattermost
|
||||
- **Configurable mention behavior** — respond to messages without requiring @mention ([#3664](https://github.com/NousResearch/hermes-agent/pull/3664))
|
||||
|
||||
### Signal
|
||||
- **URL-encode phone numbers** and correct attachment RPC parameter — fixes delivery failures with certain phone number formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) — @kshitijk4poor
|
||||
|
||||
### Email
|
||||
- **Close SMTP/IMAP connections on failure** — prevents connection leaks during error scenarios ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804))
|
||||
|
||||
### Gateway Core
|
||||
- **Atomic config writes** — use atomic file writes for config.yaml to prevent data loss during crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800))
|
||||
- **Home channel env overrides** — apply environment variable overrides for home channels consistently ([#3796](https://github.com/NousResearch/hermes-agent/pull/3796), [#3808](https://github.com/NousResearch/hermes-agent/pull/3808))
|
||||
- **Replace print() with logger** — BasePlatformAdapter now uses proper logging instead of print statements ([#3669](https://github.com/NousResearch/hermes-agent/pull/3669))
|
||||
- **Cron delivery labels** — resolve human-friendly delivery labels via channel directory ([#3860](https://github.com/NousResearch/hermes-agent/pull/3860), closes [#1945](https://github.com/NousResearch/hermes-agent/issues/1945))
|
||||
- **Cron [SILENT] tightening** — prevent agents from prefixing reports with [SILENT] to suppress delivery ([#3901](https://github.com/NousResearch/hermes-agent/pull/3901))
|
||||
- **Background task media delivery** and vision download timeout fixes ([#3919](https://github.com/NousResearch/hermes-agent/pull/3919))
|
||||
- **Boot-md hook** — example built-in hook to run a BOOT.md file on gateway startup ([#3733](https://github.com/NousResearch/hermes-agent/pull/3733))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
### Interactive CLI
|
||||
- **Configurable tool preview length** — show full file paths by default instead of truncating at 40 chars ([#3841](https://github.com/NousResearch/hermes-agent/pull/3841))
|
||||
- **Tool token context display** — `hermes tools` checklist now shows estimated token cost per toolset ([#3805](https://github.com/NousResearch/hermes-agent/pull/3805))
|
||||
- **/bg spinner TUI fix** — route background task spinner through the TUI widget to prevent status bar collision ([#3643](https://github.com/NousResearch/hermes-agent/pull/3643))
|
||||
- **Prevent status bar wrapping** into duplicate rows ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) — @kshitijk4poor
|
||||
- **Handle closed stdout ValueError** in safe print paths — fixes crashes when stdout is closed during gateway thread shutdown ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843), closes [#3534](https://github.com/NousResearch/hermes-agent/issues/3534))
|
||||
- **Remove input() from /tools disable** — eliminates freeze in terminal when disabling tools ([#3918](https://github.com/NousResearch/hermes-agent/pull/3918))
|
||||
- **TTY guard for interactive CLI commands** — prevent CPU spin when launched without a terminal ([#3933](https://github.com/NousResearch/hermes-agent/pull/3933))
|
||||
- **Argparse entrypoint** — use argparse in the top-level launcher for cleaner error handling ([#3874](https://github.com/NousResearch/hermes-agent/pull/3874))
|
||||
- **Lazy-initialized tools show yellow** in banner instead of red, reducing false alarm about "missing" tools ([#3822](https://github.com/NousResearch/hermes-agent/pull/3822))
|
||||
- **Honcho tools shown in banner** when configured ([#3810](https://github.com/NousResearch/hermes-agent/pull/3810))
|
||||
|
||||
### Setup & Configuration
|
||||
- **Auto-install matrix-nio** during `hermes setup` when Matrix is selected ([#3802](https://github.com/NousResearch/hermes-agent/pull/3802), [#3873](https://github.com/NousResearch/hermes-agent/pull/3873))
|
||||
- **Session export stdout support** — export sessions to stdout with `-` for piping ([#3641](https://github.com/NousResearch/hermes-agent/pull/3641), closes [#3609](https://github.com/NousResearch/hermes-agent/issues/3609))
|
||||
- **Configurable approval timeouts** — set how long dangerous command approval prompts wait before auto-denying ([#3886](https://github.com/NousResearch/hermes-agent/pull/3886), closes [#3765](https://github.com/NousResearch/hermes-agent/issues/3765))
|
||||
- **Clear __pycache__ during update** — prevents stale bytecode ImportError after `hermes update` ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### MCP
|
||||
- **MCP Server Mode** — `hermes mcp serve` exposes conversations, sessions, and attachments to MCP clients via stdio or Streamable HTTP ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795))
|
||||
- **Dynamic tool discovery** — respond to `notifications/tools/list_changed` events to pick up new tools from MCP servers without reconnecting ([#3812](https://github.com/NousResearch/hermes-agent/pull/3812))
|
||||
- **Non-deprecated HTTP transport** — switched from `sse_client` to `streamable_http_client` ([#3646](https://github.com/NousResearch/hermes-agent/pull/3646))
|
||||
|
||||
### Web Tools
|
||||
- **Exa search backend** — alternative to Firecrawl and DuckDuckGo for web search and extraction ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648))
|
||||
|
||||
### Browser
|
||||
- **Guard against None LLM responses** in browser snapshot and vision tools ([#3642](https://github.com/NousResearch/hermes-agent/pull/3642))
|
||||
|
||||
### Terminal & Remote Backends
|
||||
- **Mount skill directories** into Modal and Docker containers ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890))
|
||||
- **Mount credential files** into remote backends with mtime+size caching ([#3671](https://github.com/NousResearch/hermes-agent/pull/3671))
|
||||
- **Preserve partial output** when commands time out instead of losing everything ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868))
|
||||
- **Stop marking persisted env vars as missing** on remote backends ([#3650](https://github.com/NousResearch/hermes-agent/pull/3650))
|
||||
|
||||
### Audio
|
||||
- **.aac format support** in transcription tool ([#3865](https://github.com/NousResearch/hermes-agent/pull/3865), closes [#1963](https://github.com/NousResearch/hermes-agent/issues/1963))
|
||||
- **Audio download retry** — retry logic for `cache_audio_from_url` matching the existing image download pattern ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) — @binhnt92
|
||||
|
||||
### Vision
|
||||
- **Reject non-image files** and enforce website-only policy for vision analysis ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845))
|
||||
|
||||
### Tool Schema
|
||||
- **Ensure name field** always present in tool definitions, fixing `KeyError: 'name'` crashes ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811), closes [#3729](https://github.com/NousResearch/hermes-agent/issues/3729))
|
||||
|
||||
### ACP (Editor Integration)
|
||||
- **Complete session management surface** for VS Code/Zed/JetBrains clients — proper task lifecycle, cancel support, session persistence ([#3675](https://github.com/NousResearch/hermes-agent/pull/3675))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills & Plugins
|
||||
|
||||
### Skills System
|
||||
- **External skill directories** — configure additional skill directories via `skills.external_dirs` in config.yaml ([#3678](https://github.com/NousResearch/hermes-agent/pull/3678))
|
||||
- **Category path traversal blocked** — prevents `../` attacks in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844))
|
||||
- **parallel-cli moved to optional-skills** — reduces default skill footprint ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)) — @kshitijk4poor
|
||||
|
||||
### New Skills
|
||||
- **memento-flashcards** — spaced repetition flashcard system ([#3827](https://github.com/NousResearch/hermes-agent/pull/3827))
|
||||
- **songwriting-and-ai-music** — songwriting craft and AI music generation prompts ([#3834](https://github.com/NousResearch/hermes-agent/pull/3834))
|
||||
- **SiYuan Note** — integration with SiYuan note-taking app ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742))
|
||||
- **Scrapling** — web scraping skill using Scrapling library ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742))
|
||||
- **one-three-one-rule** — communication framework skill ([#3797](https://github.com/NousResearch/hermes-agent/pull/3797))
|
||||
|
||||
### Plugin System
|
||||
- **Plugin enable/disable commands** — `hermes plugins enable/disable <name>` for managing plugin state without removing them ([#3747](https://github.com/NousResearch/hermes-agent/pull/3747))
|
||||
- **Plugin message injection** — plugins can now inject messages into the conversation stream on behalf of the user via `ctx.inject_message()` ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) — @winglian
|
||||
- **Honcho self-hosted support** — allow local Honcho instances without requiring an API key ([#3644](https://github.com/NousResearch/hermes-agent/pull/3644))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Security Hardening
|
||||
- **Hardened dangerous command detection** — expanded pattern matching for risky shell commands and added file tool path guards for sensitive locations (`/etc/`, `/boot/`, docker.sock) ([#3872](https://github.com/NousResearch/hermes-agent/pull/3872))
|
||||
- **Sensitive path write checks** in approval system — catch writes to system config files through file tools, not just terminal ([#3859](https://github.com/NousResearch/hermes-agent/pull/3859))
|
||||
- **Secret redaction expansion** — now covers ElevenLabs, Tavily, and Exa API keys ([#3920](https://github.com/NousResearch/hermes-agent/pull/3920))
|
||||
- **Vision file rejection** — reject non-image files passed to vision analysis to prevent information disclosure ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845))
|
||||
- **Category path traversal blocking** — prevent directory traversal in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844))
|
||||
|
||||
### Reliability
|
||||
- **Atomic config.yaml writes** — prevent data loss during gateway crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800))
|
||||
- **Clear __pycache__ on update** — prevent stale bytecode from causing ImportError after updates ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819))
|
||||
- **Lazy imports for update safety** — prevent ImportError chains during `hermes update` when modules reference new functions ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776))
|
||||
- **Restore terminalbench2 from patch corruption** — recovered file damaged by patch tool's secret redaction ([#3801](https://github.com/NousResearch/hermes-agent/pull/3801))
|
||||
- **Terminal timeout preserves partial output** — no more lost command output on timeout ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
- **OpenClaw migration model config overwrite** — migration no longer overwrites model config dict with a string ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) — @0xbyt4
|
||||
- **OpenClaw migration expanded** — covers full data footprint including sessions, cron, memory ([#3869](https://github.com/NousResearch/hermes-agent/pull/3869))
|
||||
- **Telegram deleted reply targets** — gracefully handle replies to deleted messages instead of crashing ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858))
|
||||
- **Discord "thinking..." persistence** — properly cleans up deferred response indicators ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674))
|
||||
- **WhatsApp LID↔phone aliases** — fixes allowlist matching failures with Linked ID format ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830))
|
||||
- **Signal URL-encoded phone numbers** — fixes delivery failures with certain formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670))
|
||||
- **Email connection leaks** — properly close SMTP/IMAP connections on error ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804))
|
||||
- **_safe_print ValueError** — no more gateway thread crashes on closed stdout ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843))
|
||||
- **Tool schema KeyError 'name'** — ensure name field always present in tool definitions ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811))
|
||||
- **api_mode stale on provider switch** — correctly clear when switching providers via `hermes model` ([#3857](https://github.com/NousResearch/hermes-agent/pull/3857))
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Resolved 10+ CI failures across hooks, tiktoken, plugins, and skill tests ([#3848](https://github.com/NousResearch/hermes-agent/pull/3848), [#3721](https://github.com/NousResearch/hermes-agent/pull/3721), [#3936](https://github.com/NousResearch/hermes-agent/pull/3936))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Comprehensive OpenClaw migration guide** — step-by-step guide for migrating from OpenClaw/Claw3D to Hermes Agent ([#3864](https://github.com/NousResearch/hermes-agent/pull/3864), [#3900](https://github.com/NousResearch/hermes-agent/pull/3900))
|
||||
- **Credential file passthrough docs** — document how to forward credential files and env vars to remote backends ([#3677](https://github.com/NousResearch/hermes-agent/pull/3677))
|
||||
- **DuckDuckGo requirements clarified** — note runtime dependency on duckduckgo-search package ([#3680](https://github.com/NousResearch/hermes-agent/pull/3680))
|
||||
- **Skills catalog updated** — added red-teaming category and optional skills listing ([#3745](https://github.com/NousResearch/hermes-agent/pull/3745))
|
||||
- **Feishu docs MDX fix** — escape angle-bracket URLs that break Docusaurus build ([#3902](https://github.com/NousResearch/hermes-agent/pull/3902))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** — 90 PRs across all subsystems
|
||||
|
||||
### Community Contributors
|
||||
- **@kshitijk4poor** — 3 PRs: Signal phone number fix ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)), parallel-cli to optional-skills ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)), status bar wrapping fix ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883))
|
||||
- **@winglian** — 1 PR: Plugin message injection interface ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778))
|
||||
- **@binhnt92** — 1 PR: Audio download retry logic ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401))
|
||||
- **@0xbyt4** — 1 PR: OpenClaw migration model config fix ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924))
|
||||
|
||||
### Issues Resolved from Community
|
||||
@Material-Scientist ([#850](https://github.com/NousResearch/hermes-agent/issues/850)), @hanxu98121 ([#1734](https://github.com/NousResearch/hermes-agent/issues/1734)), @penwyp ([#1788](https://github.com/NousResearch/hermes-agent/issues/1788)), @dan-and ([#1945](https://github.com/NousResearch/hermes-agent/issues/1945)), @AdrianScott ([#1963](https://github.com/NousResearch/hermes-agent/issues/1963)), @clawdbot47 ([#3229](https://github.com/NousResearch/hermes-agent/issues/3229)), @alanfwilliams ([#3404](https://github.com/NousResearch/hermes-agent/issues/3404)), @kentimsit ([#3433](https://github.com/NousResearch/hermes-agent/issues/3433)), @hayka-pacha ([#3534](https://github.com/NousResearch/hermes-agent/issues/3534)), @primmer ([#3595](https://github.com/NousResearch/hermes-agent/issues/3595)), @dagelf ([#3609](https://github.com/NousResearch/hermes-agent/issues/3609)), @HenkDz ([#3685](https://github.com/NousResearch/hermes-agent/issues/3685)), @tmdgusya ([#3729](https://github.com/NousResearch/hermes-agent/issues/3729)), @TypQxQ ([#3753](https://github.com/NousResearch/hermes-agent/issues/3753)), @acsezen ([#3765](https://github.com/NousResearch/hermes-agent/issues/3765))
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.3.28...v2026.3.30](https://github.com/NousResearch/hermes-agent/compare/v2026.3.28...v2026.3.30)
|
||||
@@ -152,35 +152,16 @@ def _is_oauth_token(key: str) -> bool:
|
||||
|
||||
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
|
||||
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
|
||||
Azure AI Foundry keys (non sk-ant- prefixed) should use x-api-key, not Bearer.
|
||||
"""
|
||||
if not key:
|
||||
return False
|
||||
# Regular Console API keys use x-api-key header
|
||||
if key.startswith("sk-ant-api"):
|
||||
return False
|
||||
# Azure AI Foundry keys don't start with sk-ant- at all — treat as regular API key
|
||||
if not key.startswith("sk-ant-"):
|
||||
return False
|
||||
# Everything else (setup-tokens sk-ant-oat, managed keys, JWTs) uses Bearer auth
|
||||
# Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth
|
||||
return True
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||
|
||||
Some third-party /anthropic endpoints implement Anthropic's Messages API but
|
||||
require Authorization: Bearer instead of Anthropic's native x-api-key header.
|
||||
MiniMax's global and China Anthropic-compatible endpoints follow this pattern.
|
||||
"""
|
||||
if not base_url:
|
||||
return False
|
||||
normalized = base_url.rstrip("/").lower()
|
||||
return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith(
|
||||
"https://api.minimaxi.com/anthropic"
|
||||
)
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
@@ -199,17 +180,7 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
|
||||
if _requires_bearer_auth(base_url):
|
||||
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
|
||||
# Authorization: Bearer even for regular API keys. Route those endpoints
|
||||
# through auth_token so the SDK sends Bearer auth instead of x-api-key.
|
||||
# Check this before OAuth token shape detection because MiniMax secrets do
|
||||
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
|
||||
# Anthropic OAuth/setup tokens.
|
||||
kwargs["auth_token"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
elif _is_oauth_token(api_key):
|
||||
if _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||
# without Claude Code's fingerprint, requests get intermittent 500s.
|
||||
|
||||
@@ -627,6 +627,8 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
|
||||
custom_key = runtime.get("api_key")
|
||||
if not isinstance(custom_base, str) or not custom_base.strip():
|
||||
return None, None
|
||||
if not isinstance(custom_key, str) or not custom_key.strip():
|
||||
return None, None
|
||||
|
||||
custom_base = custom_base.strip().rstrip("/")
|
||||
if "openrouter.ai" in custom_base.lower():
|
||||
@@ -634,13 +636,6 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
|
||||
# configured. Treat that as "no custom endpoint" for auxiliary routing.
|
||||
return None, None
|
||||
|
||||
# Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth.
|
||||
# Use a placeholder key — the OpenAI SDK requires a non-empty string but
|
||||
# local servers ignore the Authorization header. Same fix as cli.py
|
||||
# _ensure_runtime_credentials() (PR #2556).
|
||||
if not isinstance(custom_key, str) or not custom_key.strip():
|
||||
custom_key = "no-key-required"
|
||||
|
||||
return custom_base, custom_key.strip()
|
||||
|
||||
|
||||
@@ -742,37 +737,16 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
|
||||
return None, None
|
||||
|
||||
|
||||
_AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
"_try_custom_endpoint": "local/custom",
|
||||
"_try_codex": "openai-codex",
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain: OpenRouter → Nous → custom → Codex → API-key → None."""
|
||||
global auxiliary_is_nous
|
||||
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
|
||||
tried = []
|
||||
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
|
||||
_try_codex, _resolve_api_key_provider):
|
||||
fn_name = getattr(try_fn, "__name__", "unknown")
|
||||
label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name)
|
||||
client, model = try_fn()
|
||||
if client is not None:
|
||||
if tried:
|
||||
logger.info("Auxiliary auto-detect: using %s (%s) — skipped: %s",
|
||||
label, model or "default", ", ".join(tried))
|
||||
else:
|
||||
logger.info("Auxiliary auto-detect: using %s (%s)", label, model or "default")
|
||||
return client, model
|
||||
tried.append(label)
|
||||
logger.warning("Auxiliary auto-detect: no provider available (tried: %s). "
|
||||
"Compression, summarization, and memory flush will not work. "
|
||||
"Set OPENROUTER_API_KEY or configure a local model in config.yaml.",
|
||||
", ".join(tried))
|
||||
logger.debug("Auxiliary client: none available")
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -923,12 +897,11 @@ def resolve_provider_client(
|
||||
custom_key = (
|
||||
(explicit_api_key or "").strip()
|
||||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
or "no-key-required" # local servers don't need auth
|
||||
)
|
||||
if not custom_base:
|
||||
if not custom_base or not custom_key:
|
||||
logger.warning(
|
||||
"resolve_provider_client: explicit custom endpoint requested "
|
||||
"but base_url is empty"
|
||||
"but no API key was found (set explicit_api_key or OPENAI_API_KEY)"
|
||||
)
|
||||
return None, None
|
||||
final_model = model or _read_main_model() or "gpt-4o-mini"
|
||||
@@ -1639,8 +1612,8 @@ def call_llm(
|
||||
)
|
||||
# For auto/custom, fall back to OpenRouter
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter",
|
||||
task or "call", resolved_provider)
|
||||
logger.warning("Provider %s unavailable, falling back to openrouter",
|
||||
resolved_provider)
|
||||
client, final_model = _get_cached_client(
|
||||
"openrouter", resolved_model or _OPENROUTER_MODEL)
|
||||
if client is None:
|
||||
@@ -1650,13 +1623,6 @@ def call_llm(
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
if task:
|
||||
logger.info("Auxiliary %s: using %s (%s)%s",
|
||||
task, resolved_provider or "auto", final_model or "default",
|
||||
f" at {_base_info}" if _base_info and "openrouter" not in _base_info else "")
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
resolved_provider, final_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
|
||||
+3
-30
@@ -17,23 +17,6 @@ _RESET = "\033[0m"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =========================================================================
|
||||
# Configurable tool preview length (0 = no limit)
|
||||
# Set once at startup by CLI or gateway from display.tool_preview_length config.
|
||||
# =========================================================================
|
||||
_tool_preview_max_len: int = 0 # 0 = unlimited
|
||||
|
||||
|
||||
def set_tool_preview_max_len(n: int) -> None:
|
||||
"""Set the global max length for tool call previews. 0 = no limit."""
|
||||
global _tool_preview_max_len
|
||||
_tool_preview_max_len = max(int(n), 0) if n else 0
|
||||
|
||||
|
||||
def get_tool_preview_max_len() -> int:
|
||||
"""Return the configured max preview length (0 = unlimited)."""
|
||||
return _tool_preview_max_len
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Skin-aware helpers (lazy import to avoid circular deps)
|
||||
@@ -111,14 +94,8 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
*max_len* controls truncation. ``None`` (default) defers to the global
|
||||
``_tool_preview_max_len`` set via config; ``0`` means unlimited.
|
||||
"""
|
||||
if max_len is None:
|
||||
max_len = _tool_preview_max_len
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display."""
|
||||
if not args:
|
||||
return None
|
||||
primary_args = {
|
||||
@@ -213,7 +190,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
preview = _oneline(str(value))
|
||||
if not preview:
|
||||
return None
|
||||
if max_len > 0 and len(preview) > max_len:
|
||||
if len(preview) > max_len:
|
||||
preview = preview[:max_len - 3] + "..."
|
||||
return preview
|
||||
|
||||
@@ -507,14 +484,10 @@ def get_cute_tool_message(
|
||||
|
||||
def _trunc(s, n=40):
|
||||
s = str(s)
|
||||
if _tool_preview_max_len == 0:
|
||||
return s # no limit
|
||||
return (s[:n-3] + "...") if len(s) > n else s
|
||||
|
||||
def _path(p, n=35):
|
||||
p = str(p)
|
||||
if _tool_preview_max_len == 0:
|
||||
return p # no limit
|
||||
return ("..." + p[-(n-3):]) if len(p) > n else p
|
||||
|
||||
def _wrap(line: str) -> str:
|
||||
|
||||
@@ -171,7 +171,6 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"dashscope.aliyuncs.com": "alibaba",
|
||||
"dashscope-intl.aliyuncs.com": "alibaba",
|
||||
"openrouter.ai": "openrouter",
|
||||
"generativelanguage.googleapis.com": "google",
|
||||
"inference-api.nousresearch.com": "nous",
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.githubcopilot.com": "copilot",
|
||||
|
||||
@@ -37,9 +37,6 @@ _PREFIX_PATTERNS = [
|
||||
r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT
|
||||
r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth
|
||||
r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key
|
||||
r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash)
|
||||
r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key
|
||||
r"exa_[A-Za-z0-9]{10,}", # Exa search API key
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
|
||||
+8
-26
@@ -11,29 +11,14 @@ model:
|
||||
default: "anthropic/claude-opus-4.6"
|
||||
|
||||
# Inference provider selection:
|
||||
# "auto" - Auto-detect from credentials (default)
|
||||
# "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY)
|
||||
# "nous" - Nous Portal OAuth (requires: hermes login)
|
||||
# "nous-api" - Nous Portal API key (requires: NOUS_API_KEY)
|
||||
# "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY)
|
||||
# "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex)
|
||||
# "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN)
|
||||
# "zai" - z.ai / ZhipuAI GLM (requires: GLM_API_KEY)
|
||||
# "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY)
|
||||
# "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
|
||||
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
|
||||
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
|
||||
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
|
||||
#
|
||||
# Local servers (LM Studio, Ollama, vLLM, llama.cpp):
|
||||
# "custom" - Any OpenAI-compatible endpoint. Set base_url below.
|
||||
# Aliases: "lmstudio", "ollama", "vllm", "llamacpp" all map to "custom".
|
||||
# Example for LM Studio:
|
||||
# provider: "lmstudio"
|
||||
# base_url: "http://localhost:1234/v1"
|
||||
# No API key needed — local servers typically ignore auth.
|
||||
#
|
||||
# "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default)
|
||||
# "nous-api" - Use Nous Portal via API key (requires: NOUS_API_KEY)
|
||||
# "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY
|
||||
# "nous" - Always use Nous Portal (requires: hermes login)
|
||||
# "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY)
|
||||
# "kimi-coding"- Use Kimi / Moonshot AI models (requires: KIMI_API_KEY)
|
||||
# "minimax" - Use MiniMax global endpoint (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - Use MiniMax China endpoint (requires: MINIMAX_CN_API_KEY)
|
||||
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
|
||||
provider: "auto"
|
||||
|
||||
@@ -324,9 +309,6 @@ compression:
|
||||
# vision:
|
||||
# provider: "auto"
|
||||
# model: "" # e.g. "google/gemini-2.5-flash", "openai/gpt-4o"
|
||||
# timeout: 30 # LLM API call timeout (seconds)
|
||||
# download_timeout: 30 # Image HTTP download timeout (seconds)
|
||||
# # Increase for slow connections or self-hosted image servers
|
||||
#
|
||||
# # Web page scraping / summarization + browser page text extraction
|
||||
# web_extract:
|
||||
|
||||
@@ -449,14 +449,6 @@ try:
|
||||
except Exception:
|
||||
pass # Skin engine is optional — default skin used if unavailable
|
||||
|
||||
# Initialize tool preview length from config
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = CLI_CONFIG.get("display", {}).get("tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are
|
||||
# created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop()
|
||||
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
|
||||
@@ -1087,10 +1079,10 @@ class HermesCLI:
|
||||
# env vars would stomp each other.
|
||||
_model_config = CLI_CONFIG.get("model", {})
|
||||
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
|
||||
_DEFAULT_CONFIG_MODEL = "anthropic/claude-opus-4.6"
|
||||
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
|
||||
# Auto-detect model from local server if still on default
|
||||
if self.model == _DEFAULT_CONFIG_MODEL:
|
||||
_FALLBACK_MODEL = "anthropic/claude-opus-4.6"
|
||||
self.model = model or _config_model or _FALLBACK_MODEL
|
||||
# Auto-detect model from local server if still on fallback
|
||||
if self.model == _FALLBACK_MODEL:
|
||||
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
|
||||
if "localhost" in _base_url or "127.0.0.1" in _base_url:
|
||||
from hermes_cli.runtime_provider import _auto_detect_local_model
|
||||
@@ -1104,7 +1096,7 @@ class HermesCLI:
|
||||
# explicit choice — the user just never changed it. But a config model
|
||||
# like "gpt-5.3-codex" IS explicit and must be preserved.
|
||||
self._model_is_default = not model and (
|
||||
not _config_model or _config_model == _DEFAULT_CONFIG_MODEL
|
||||
not _config_model or _config_model == _FALLBACK_MODEL
|
||||
)
|
||||
|
||||
self._explicit_api_key = api_key
|
||||
@@ -1355,49 +1347,6 @@ class HermesCLI:
|
||||
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _status_bar_display_width(text: str) -> int:
|
||||
"""Return terminal cell width for status-bar text.
|
||||
|
||||
len() is not enough for prompt_toolkit layout decisions because some
|
||||
glyphs can render wider than one Python codepoint. Keeping the status
|
||||
bar within the real display width prevents it from wrapping onto a
|
||||
second line and leaving behind duplicate rows.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
return get_cwidth(text or "")
|
||||
except Exception:
|
||||
return len(text or "")
|
||||
|
||||
@classmethod
|
||||
def _trim_status_bar_text(cls, text: str, max_width: int) -> str:
|
||||
"""Trim status-bar text to a single terminal row."""
|
||||
if max_width <= 0:
|
||||
return ""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
except Exception:
|
||||
get_cwidth = None
|
||||
|
||||
if cls._status_bar_display_width(text) <= max_width:
|
||||
return text
|
||||
|
||||
ellipsis = "..."
|
||||
ellipsis_width = cls._status_bar_display_width(ellipsis)
|
||||
if max_width <= ellipsis_width:
|
||||
return ellipsis[:max_width]
|
||||
|
||||
out = []
|
||||
width = 0
|
||||
for ch in text:
|
||||
ch_width = get_cwidth(ch) if get_cwidth else len(ch)
|
||||
if width + ch_width + ellipsis_width > max_width:
|
||||
break
|
||||
out.append(ch)
|
||||
width += ch_width
|
||||
return "".join(out).rstrip() + ellipsis
|
||||
|
||||
def _build_status_bar_text(self, width: Optional[int] = None) -> str:
|
||||
try:
|
||||
snapshot = self._get_status_bar_snapshot()
|
||||
@@ -1412,12 +1361,11 @@ class HermesCLI:
|
||||
duration_label = snapshot["duration"]
|
||||
|
||||
if width < 52:
|
||||
text = f"⚕ {snapshot['model_short']} · {duration_label}"
|
||||
return self._trim_status_bar_text(text, width)
|
||||
return f"⚕ {snapshot['model_short']} · {duration_label}"
|
||||
if width < 76:
|
||||
parts = [f"⚕ {snapshot['model_short']}", percent_label]
|
||||
parts.append(duration_label)
|
||||
return self._trim_status_bar_text(" · ".join(parts), width)
|
||||
return " · ".join(parts)
|
||||
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
@@ -1428,7 +1376,7 @@ class HermesCLI:
|
||||
|
||||
parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label]
|
||||
parts.append(duration_label)
|
||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||
return " │ ".join(parts)
|
||||
except Exception:
|
||||
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
||||
|
||||
@@ -1450,54 +1398,53 @@ class HermesCLI:
|
||||
duration_label = snapshot["duration"]
|
||||
|
||||
if width < 52:
|
||||
frags = [
|
||||
return [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
|
||||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
if width < 76:
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
(self._status_bar_context_style(percent), percent_label),
|
||||
]
|
||||
frags.extend([
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
])
|
||||
return frags
|
||||
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
||||
context_label = f"{ctx_used}/{ctx_total}"
|
||||
else:
|
||||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
if width < 76:
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
(self._status_bar_context_style(percent), percent_label),
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
else:
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
||||
context_label = f"{ctx_used}/{ctx_total}"
|
||||
else:
|
||||
context_label = "ctx --"
|
||||
context_label = "ctx --"
|
||||
|
||||
bar_style = self._status_bar_context_style(percent)
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", context_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
(bar_style, self._build_context_bar(percent)),
|
||||
("class:status-bar-dim", " "),
|
||||
(bar_style, percent_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
]
|
||||
|
||||
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
|
||||
if total_width > width:
|
||||
plain_text = "".join(text for _, text in frags)
|
||||
trimmed = self._trim_status_bar_text(plain_text, width)
|
||||
return [("class:status-bar", trimmed)]
|
||||
bar_style = self._status_bar_context_style(percent)
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", context_label),
|
||||
("class:status-bar-dim", " │ "),
|
||||
(bar_style, self._build_context_bar(percent)),
|
||||
("class:status-bar-dim", " "),
|
||||
(bar_style, percent_label),
|
||||
]
|
||||
frags.extend([
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
])
|
||||
return frags
|
||||
except Exception:
|
||||
return [("class:status-bar", f" {self._build_status_bar_text()} ")]
|
||||
@@ -2789,12 +2736,22 @@ class HermesCLI:
|
||||
print(f" MCP tool: /tools {subcommand} github:create_issue")
|
||||
return
|
||||
|
||||
# Apply the change directly — the user typing the command is implicit
|
||||
# consent. Do NOT use input() here; it hangs inside prompt_toolkit's
|
||||
# TUI event loop (known pitfall).
|
||||
verb = "Disabling" if subcommand == "disable" else "Enabling"
|
||||
# Confirm session reset before applying
|
||||
verb = "Disable" if subcommand == "disable" else "Enable"
|
||||
label = ", ".join(names)
|
||||
_cprint(f"{_GOLD}{verb} {label}...{_RST}")
|
||||
_cprint(f"{_GOLD}{verb} {label}?{_RST}")
|
||||
_cprint(f"{_DIM}This will save to config and reset your session so the "
|
||||
f"change takes effect cleanly.{_RST}")
|
||||
try:
|
||||
answer = input(" Continue? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
_cprint(f"{_DIM}Cancelled.{_RST}")
|
||||
return
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
_cprint(f"{_DIM}Cancelled.{_RST}")
|
||||
return
|
||||
|
||||
tools_disable_enable_command(
|
||||
Namespace(tools_action=subcommand, names=names, platform="cli"))
|
||||
@@ -2837,28 +2794,6 @@ class HermesCLI:
|
||||
print(" Example: python cli.py --toolsets web,terminal")
|
||||
print()
|
||||
|
||||
def _handle_profile_command(self):
|
||||
"""Display active profile name and home directory."""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
print()
|
||||
if profile_name:
|
||||
print(f" Profile: {profile_name}")
|
||||
else:
|
||||
print(" Profile: default")
|
||||
print(f" Home: {display}")
|
||||
print()
|
||||
|
||||
def show_config(self):
|
||||
"""Display current configuration with kawaii ASCII art."""
|
||||
# Get terminal config from environment (which was set from cli-config.yaml)
|
||||
@@ -3701,8 +3636,6 @@ class HermesCLI:
|
||||
return False
|
||||
elif canonical == "help":
|
||||
self.show_help()
|
||||
elif canonical == "profile":
|
||||
self._handle_profile_command()
|
||||
elif canonical == "tools":
|
||||
self._handle_tools_command(cmd_original)
|
||||
elif canonical == "toolsets":
|
||||
@@ -3860,8 +3793,6 @@ class HermesCLI:
|
||||
self.console.print(f" Status bar {state}")
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "yolo":
|
||||
self._toggle_yolo()
|
||||
elif canonical == "reasoning":
|
||||
self._handle_reasoning_command(cmd_original)
|
||||
elif canonical == "compress":
|
||||
@@ -4460,17 +4391,6 @@ class HermesCLI:
|
||||
}
|
||||
_cprint(labels.get(self.tool_progress_mode, ""))
|
||||
|
||||
def _toggle_yolo(self):
|
||||
"""Toggle YOLO mode — skip all dangerous command approval prompts."""
|
||||
import os
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
self.console.print(" ⚠ YOLO mode [bold red]OFF[/] — dangerous commands will require approval.")
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
self.console.print(" ⚡ YOLO mode [bold green]ON[/] — all commands auto-approved. Use with caution.")
|
||||
|
||||
def _handle_reasoning_command(self, cmd: str):
|
||||
"""Handle /reasoning — manage effort level and display toggle.
|
||||
|
||||
@@ -4862,10 +4782,8 @@ class HermesCLI:
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(function_name)
|
||||
label = preview or function_name
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(label) > _pl:
|
||||
label = label[:_pl - 3] + "..."
|
||||
if len(label) > 50:
|
||||
label = label[:47] + "..."
|
||||
self._spinner_text = f"{emoji} {label}"
|
||||
self._invalidate()
|
||||
|
||||
@@ -5597,8 +5515,6 @@ class HermesCLI:
|
||||
self.agent = None
|
||||
|
||||
# Initialize agent if needed
|
||||
if self.agent is None:
|
||||
_cprint(f"{_DIM}Initializing agent...{_RST}")
|
||||
if not self._init_agent(
|
||||
model_override=turn_route["model"],
|
||||
runtime_override=turn_route["runtime"],
|
||||
@@ -6239,11 +6155,6 @@ class HermesCLI:
|
||||
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
|
||||
self._should_exit = False
|
||||
self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit
|
||||
|
||||
# Give plugin manager a CLI reference so plugins can inject messages
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
get_plugin_manager()._cli_ref = self
|
||||
|
||||
# Config file watcher — detect mcp_servers changes and auto-reload
|
||||
from hermes_cli.config import get_config_path as _get_config_path
|
||||
_cfg_path = _get_config_path()
|
||||
|
||||
@@ -375,6 +375,7 @@ def create_job(
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
script: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new cron job.
|
||||
@@ -448,6 +449,8 @@ def create_job(
|
||||
# Delivery configuration
|
||||
"deliver": deliver,
|
||||
"origin": origin, # Tracks where job was created for "origin" delivery
|
||||
# Script gate: optional bash script run before waking the agent
|
||||
"script": script,
|
||||
}
|
||||
|
||||
jobs = load_jobs()
|
||||
|
||||
+78
-25
@@ -12,7 +12,9 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
# fcntl is Unix-only; on Windows use msvcrt for file locking
|
||||
@@ -87,22 +89,6 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
chat_id, thread_id = rest.split(":", 1)
|
||||
else:
|
||||
chat_id, thread_id = rest, None
|
||||
|
||||
# Resolve human-friendly labels like "Alice (dm)" to real IDs.
|
||||
# send_message(action="list") shows labels with display suffixes
|
||||
# that aren't valid platform IDs (e.g. WhatsApp JIDs).
|
||||
try:
|
||||
from gateway.channel_directory import resolve_channel_name
|
||||
target = chat_id
|
||||
# Strip display suffix like " (dm)" or " (group)"
|
||||
if target.endswith(")") and " (" in target:
|
||||
target = target.rsplit(" (", 1)[0].strip()
|
||||
resolved = resolve_channel_name(platform_name.lower(), target)
|
||||
if resolved:
|
||||
chat_id = resolved
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id,
|
||||
@@ -162,8 +148,6 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||
"mattermost": Platform.MATTERMOST,
|
||||
"homeassistant": Platform.HOMEASSISTANT,
|
||||
"dingtalk": Platform.DINGTALK,
|
||||
"feishu": Platform.FEISHU,
|
||||
"wecom": Platform.WECOM,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
}
|
||||
@@ -236,12 +220,11 @@ def _build_job_prompt(job: dict) -> str:
|
||||
# Always prepend [SILENT] guidance so the cron agent can suppress
|
||||
# delivery when it has nothing new or noteworthy to report.
|
||||
silent_hint = (
|
||||
"[SYSTEM: If you have a meaningful status report or findings, "
|
||||
"send them — that is the whole point of this job. Only respond "
|
||||
"with exactly \"[SILENT]\" (nothing else) when there is genuinely "
|
||||
"nothing new to report. [SILENT] suppresses delivery to the user. "
|
||||
"Never combine [SILENT] with content — either report your "
|
||||
"findings normally, or say [SILENT] and nothing more.]\n\n"
|
||||
"[SYSTEM: If you have nothing new or noteworthy to report, respond "
|
||||
"with exactly \"[SILENT]\" (optionally followed by a brief internal "
|
||||
"note). This suppresses delivery to the user while still saving "
|
||||
"output locally. Only use [SILENT] when there are genuinely no "
|
||||
"changes worth reporting.]\n\n"
|
||||
)
|
||||
prompt = silent_hint + prompt
|
||||
if skills is None:
|
||||
@@ -313,6 +296,76 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
origin = _resolve_origin(job)
|
||||
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
# --- Script gate: run optional pre-check script before waking the agent ---
|
||||
script_source = job.get("script")
|
||||
if script_source:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".sh", delete=False
|
||||
) as tmp:
|
||||
tmp.write(script_source)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
script_result = subprocess.run(
|
||||
["bash", tmp_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Parse the last non-empty line of stdout as JSON
|
||||
stdout_lines = [
|
||||
line for line in script_result.stdout.splitlines() if line.strip()
|
||||
]
|
||||
if stdout_lines:
|
||||
last_line = stdout_lines[-1].strip()
|
||||
try:
|
||||
gate = json.loads(last_line)
|
||||
if isinstance(gate, dict):
|
||||
wake = gate.get("wakeAgent", True)
|
||||
if not wake:
|
||||
output_doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"**Schedule:** {job.get('schedule_display', 'N/A')}\n\n"
|
||||
f"## Script Gate\n\nAgent skipped by script gate.\n"
|
||||
)
|
||||
logger.info(
|
||||
"Job '%s': script gate returned wakeAgent=false, skipping agent",
|
||||
job_name,
|
||||
)
|
||||
return True, output_doc, "Script gate: agent skipped", None
|
||||
# wakeAgent is true — check for data to prepend
|
||||
data = gate.get("data")
|
||||
if data is not None:
|
||||
prompt = (
|
||||
f"Script pre-check data:\n{json.dumps(data)}\n\n{prompt}"
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning(
|
||||
"Job '%s': script gate output not valid JSON, proceeding normally: %s",
|
||||
job_name,
|
||||
last_line[:200],
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"Job '%s': script gate timed out after 30s, proceeding normally",
|
||||
job_name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Job '%s': script gate error (%s), proceeding normally",
|
||||
job_name,
|
||||
e,
|
||||
)
|
||||
# --- End script gate ---
|
||||
|
||||
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
|
||||
logger.info("Prompt: %s", prompt[:100])
|
||||
|
||||
@@ -339,7 +392,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
if delivery_target.get("thread_id") is not None:
|
||||
os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"])
|
||||
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
|
||||
|
||||
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
|
||||
_cfg = {}
|
||||
|
||||
+2
-84
@@ -27,16 +27,9 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
return default
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
@@ -64,8 +57,6 @@ class Platform(Enum):
|
||||
DINGTALK = "dingtalk"
|
||||
API_SERVER = "api_server"
|
||||
WEBHOOK = "webhook"
|
||||
FEISHU = "feishu"
|
||||
WECOM = "wecom"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -283,12 +274,6 @@ class GatewayConfig:
|
||||
# Webhook uses enabled flag only (secrets are per-route)
|
||||
elif platform == Platform.WEBHOOK:
|
||||
connected.append(platform)
|
||||
# Feishu uses extra dict for app credentials
|
||||
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
||||
connected.append(platform)
|
||||
# WeCom uses extra dict for bot credentials
|
||||
elif platform == Platform.WECOM and config.extra.get("bot_id"):
|
||||
connected.append(platform)
|
||||
return connected
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
@@ -522,10 +507,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
)
|
||||
if "reply_prefix" in platform_cfg:
|
||||
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
||||
if "require_mention" in platform_cfg:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if "mention_patterns" in platform_cfg:
|
||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
||||
if not bridged:
|
||||
continue
|
||||
plat_data = platforms_data.setdefault(plat.value, {})
|
||||
@@ -550,20 +531,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
|
||||
# Telegram settings → env vars (env vars take precedence)
|
||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||
if isinstance(telegram_cfg, dict):
|
||||
if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
|
||||
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||
import json as _json
|
||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
|
||||
frc = telegram_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process config.yaml — falling back to .env / gateway.json values. "
|
||||
@@ -843,55 +810,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
if webhook_secret:
|
||||
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
|
||||
|
||||
# Feishu / Lark
|
||||
feishu_app_id = os.getenv("FEISHU_APP_ID")
|
||||
feishu_app_secret = os.getenv("FEISHU_APP_SECRET")
|
||||
if feishu_app_id and feishu_app_secret:
|
||||
if Platform.FEISHU not in config.platforms:
|
||||
config.platforms[Platform.FEISHU] = PlatformConfig()
|
||||
config.platforms[Platform.FEISHU].enabled = True
|
||||
config.platforms[Platform.FEISHU].extra.update({
|
||||
"app_id": feishu_app_id,
|
||||
"app_secret": feishu_app_secret,
|
||||
"domain": os.getenv("FEISHU_DOMAIN", "feishu"),
|
||||
"connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"),
|
||||
})
|
||||
feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "")
|
||||
if feishu_encrypt_key:
|
||||
config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
|
||||
feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "")
|
||||
if feishu_verification_token:
|
||||
config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token
|
||||
feishu_home = os.getenv("FEISHU_HOME_CHANNEL")
|
||||
if feishu_home:
|
||||
config.platforms[Platform.FEISHU].home_channel = HomeChannel(
|
||||
platform=Platform.FEISHU,
|
||||
chat_id=feishu_home,
|
||||
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# WeCom (Enterprise WeChat)
|
||||
wecom_bot_id = os.getenv("WECOM_BOT_ID")
|
||||
wecom_secret = os.getenv("WECOM_SECRET")
|
||||
if wecom_bot_id and wecom_secret:
|
||||
if Platform.WECOM not in config.platforms:
|
||||
config.platforms[Platform.WECOM] = PlatformConfig()
|
||||
config.platforms[Platform.WECOM].enabled = True
|
||||
config.platforms[Platform.WECOM].extra.update({
|
||||
"bot_id": wecom_bot_id,
|
||||
"secret": wecom_secret,
|
||||
})
|
||||
wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "")
|
||||
if wecom_ws_url:
|
||||
config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url
|
||||
wecom_home = os.getenv("WECOM_HOME_CHANNEL")
|
||||
if wecom_home:
|
||||
config.platforms[Platform.WECOM].home_channel = HomeChannel(
|
||||
platform=Platform.WECOM,
|
||||
chat_id=wecom_home,
|
||||
name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
||||
@@ -898,26 +898,6 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Processing lifecycle hooks ──────────────────────────────────────────
|
||||
# Subclasses override these to react to message processing events
|
||||
# (e.g. Discord adds 👀/✅/❌ reactions).
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Hook called when background processing begins."""
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
"""Hook called when background processing completes."""
|
||||
|
||||
async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Run a lifecycle hook without letting failures break message flow."""
|
||||
hook = getattr(self, hook_name, None)
|
||||
if not callable(hook):
|
||||
return
|
||||
try:
|
||||
await hook(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e)
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_error(error: Optional[str]) -> bool:
|
||||
"""Return True if the error string looks like a transient network failure."""
|
||||
@@ -1080,18 +1060,6 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
|
||||
"""Background task that actually processes the message."""
|
||||
# Track delivery outcomes for the processing-complete hook
|
||||
delivery_attempted = False
|
||||
delivery_succeeded = False
|
||||
|
||||
def _record_delivery(result):
|
||||
nonlocal delivery_attempted, delivery_succeeded
|
||||
if result is None:
|
||||
return
|
||||
delivery_attempted = True
|
||||
if getattr(result, "success", False):
|
||||
delivery_succeeded = True
|
||||
|
||||
# Create interrupt event for this session
|
||||
interrupt_event = asyncio.Event()
|
||||
self._active_sessions[session_key] = interrupt_event
|
||||
@@ -1101,8 +1069,6 @@ class BasePlatformAdapter(ABC):
|
||||
typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata))
|
||||
|
||||
try:
|
||||
await self._run_processing_hook("on_processing_start", event)
|
||||
|
||||
# Call the handler (this can take a while with tool calls)
|
||||
response = await self._message_handler(event)
|
||||
|
||||
@@ -1172,7 +1138,6 @@ class BasePlatformAdapter(ABC):
|
||||
reply_to=event.message_id,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
# Human-like pacing delay between text and media
|
||||
human_delay = self._get_human_delay()
|
||||
@@ -1272,10 +1237,6 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# Determine overall success for the processing hook
|
||||
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
|
||||
await self._run_processing_hook("on_processing_complete", event, processing_ok)
|
||||
|
||||
# Check if there's a pending message that was queued during our processing
|
||||
if session_key in self._pending_messages:
|
||||
pending_event = self._pending_messages.pop(session_key)
|
||||
@@ -1292,11 +1253,7 @@ class BasePlatformAdapter(ABC):
|
||||
await self._process_message_background(pending_event, session_key)
|
||||
return # Already cleaned up
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._run_processing_hook("on_processing_complete", event, False)
|
||||
raise
|
||||
except Exception as e:
|
||||
await self._run_processing_hook("on_processing_complete", event, False)
|
||||
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
|
||||
# Send the error to the user so they aren't left with radio silence
|
||||
try:
|
||||
|
||||
@@ -660,41 +660,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
pass
|
||||
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
async def _add_reaction(self, message: Any, emoji: str) -> bool:
|
||||
"""Add an emoji reaction to a Discord message."""
|
||||
if not message or not hasattr(message, "add_reaction"):
|
||||
return False
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e)
|
||||
return False
|
||||
|
||||
async def _remove_reaction(self, message: Any, emoji: str) -> bool:
|
||||
"""Remove the bot's own emoji reaction from a Discord message."""
|
||||
if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user:
|
||||
return False
|
||||
try:
|
||||
await message.remove_reaction(emoji, self._client.user)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e)
|
||||
return False
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Add an in-progress reaction for normal Discord message events."""
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._add_reaction(message, "👀")
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
"""Swap the in-progress reaction for a final success/failure reaction."""
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._remove_reaction(message, "👀")
|
||||
await self._add_reaction(message, "✅" if success else "❌")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+12
-219
@@ -17,8 +17,6 @@ Environment variables:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -49,14 +47,6 @@ _STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
# E2EE key export file for persistence across restarts.
|
||||
_KEY_EXPORT_FILE = _STORE_DIR / "exported_keys.txt"
|
||||
_KEY_EXPORT_PASSPHRASE = "hermes-matrix-e2ee-keys"
|
||||
|
||||
# Pending undecrypted events: cap and TTL for retry buffer.
|
||||
_MAX_PENDING_EVENTS = 100
|
||||
_PENDING_EVENT_TTL = 300 # seconds — stop retrying after 5 min
|
||||
|
||||
|
||||
def check_matrix_requirements() -> bool:
|
||||
"""Return True if the Matrix adapter can be used."""
|
||||
@@ -119,10 +109,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._processed_events: deque = deque(maxlen=1000)
|
||||
self._processed_events_set: set = set()
|
||||
|
||||
# Buffer for undecrypted events pending key receipt.
|
||||
# Each entry: (room, event, timestamp)
|
||||
self._pending_megolm: list = []
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
if not event_id:
|
||||
@@ -244,16 +230,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.info("Matrix: E2EE crypto initialized")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: crypto init issue: %s", exc)
|
||||
|
||||
# Import previously exported Megolm keys (survives restarts).
|
||||
if _KEY_EXPORT_FILE.exists():
|
||||
try:
|
||||
await client.import_keys(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
logger.info("Matrix: imported Megolm keys from backup")
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not import keys: %s", exc)
|
||||
elif self._encryption:
|
||||
logger.warning(
|
||||
"Matrix: E2EE requested but crypto store is not loaded; "
|
||||
@@ -308,18 +284,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# Export Megolm keys before closing so the next restart can decrypt
|
||||
# events that used sessions from this run.
|
||||
if self._client and self._encryption and getattr(self._client, "olm", None):
|
||||
try:
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
await self._client.export_keys(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
logger.info("Matrix: exported Megolm keys for next restart")
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not export keys on disconnect: %s", exc)
|
||||
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
@@ -548,11 +512,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Upload an audio file as a voice message (MSC3245 native voice)."""
|
||||
return await self._send_local_file(
|
||||
chat_id, audio_path, "m.audio", caption, reply_to,
|
||||
metadata=metadata, is_voice=True
|
||||
)
|
||||
"""Upload an audio file as a voice message."""
|
||||
return await self._send_local_file(chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
@@ -585,16 +546,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
is_voice: bool = False,
|
||||
) -> SendResult:
|
||||
"""Upload bytes to Matrix and send as a media message."""
|
||||
import nio
|
||||
|
||||
# Upload to homeserver.
|
||||
# nio expects a DataProvider (callable) or file-like object, not raw bytes.
|
||||
# nio.upload() returns a tuple (UploadResponse|UploadError, Optional[Dict])
|
||||
resp, maybe_encryption_info = await self._client.upload(
|
||||
io.BytesIO(data),
|
||||
resp = await self._client.upload(
|
||||
data,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
)
|
||||
@@ -616,10 +574,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
},
|
||||
}
|
||||
|
||||
# Add MSC3245 voice flag for native voice messages.
|
||||
if is_voice:
|
||||
msg_content["org.matrix.msc3245.voice"] = {}
|
||||
|
||||
if reply_to:
|
||||
msg_content["m.relates_to"] = {
|
||||
"m.in_reply_to": {"event_id": reply_to}
|
||||
@@ -647,7 +601,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
is_voice: bool = False,
|
||||
) -> SendResult:
|
||||
"""Read a local file and upload it."""
|
||||
p = Path(file_path)
|
||||
@@ -660,7 +613,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
||||
data = p.read_bytes()
|
||||
|
||||
return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice)
|
||||
return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync loop
|
||||
@@ -699,22 +652,17 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
Hermes uses a custom sync loop instead of matrix-nio's sync_forever(),
|
||||
so we need to explicitly drive the key management work that sync_forever()
|
||||
normally handles for encrypted rooms.
|
||||
|
||||
Also auto-trusts all devices (so senders share session keys with us)
|
||||
and retries decryption for any buffered MegolmEvents.
|
||||
"""
|
||||
client = self._client
|
||||
if not client or not self._encryption or not getattr(client, "olm", None):
|
||||
return
|
||||
|
||||
did_query_keys = client.should_query_keys
|
||||
|
||||
tasks = [asyncio.create_task(client.send_to_device_messages())]
|
||||
|
||||
if client.should_upload_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_upload()))
|
||||
|
||||
if did_query_keys:
|
||||
if client.should_query_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_query()))
|
||||
|
||||
if client.should_claim_keys:
|
||||
@@ -730,111 +678,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: E2EE maintenance task failed: %s", exc)
|
||||
|
||||
# After key queries, auto-trust all devices so senders share keys with
|
||||
# us. For a bot this is the right default — we want to decrypt
|
||||
# everything, not enforce manual verification.
|
||||
if did_query_keys:
|
||||
self._auto_trust_devices()
|
||||
|
||||
# Retry any buffered undecrypted events now that new keys may have
|
||||
# arrived (from key requests, key queries, or to-device forwarding).
|
||||
if self._pending_megolm:
|
||||
await self._retry_pending_decryptions()
|
||||
|
||||
def _auto_trust_devices(self) -> None:
|
||||
"""Trust/verify all unverified devices we know about.
|
||||
|
||||
When other clients see our device as verified, they proactively share
|
||||
Megolm session keys with us. Without this, many clients will refuse
|
||||
to include an unverified device in key distributions.
|
||||
"""
|
||||
client = self._client
|
||||
if not client:
|
||||
return
|
||||
|
||||
device_store = getattr(client, "device_store", None)
|
||||
if not device_store:
|
||||
return
|
||||
|
||||
own_device = getattr(client, "device_id", None)
|
||||
trusted_count = 0
|
||||
|
||||
try:
|
||||
# DeviceStore.__iter__ yields OlmDevice objects directly.
|
||||
for device in device_store:
|
||||
if getattr(device, "device_id", None) == own_device:
|
||||
continue
|
||||
if not getattr(device, "verified", False):
|
||||
client.verify_device(device)
|
||||
trusted_count += 1
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: auto-trust error: %s", exc)
|
||||
|
||||
if trusted_count:
|
||||
logger.info("Matrix: auto-trusted %d new device(s)", trusted_count)
|
||||
|
||||
async def _retry_pending_decryptions(self) -> None:
|
||||
"""Retry decrypting buffered MegolmEvents after new keys arrive."""
|
||||
import nio
|
||||
|
||||
client = self._client
|
||||
if not client or not self._pending_megolm:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
still_pending: list = []
|
||||
|
||||
for room, event, ts in self._pending_megolm:
|
||||
# Drop events that have aged past the TTL.
|
||||
if now - ts > _PENDING_EVENT_TTL:
|
||||
logger.debug(
|
||||
"Matrix: dropping expired pending event %s (age %.0fs)",
|
||||
getattr(event, "event_id", "?"), now - ts,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
decrypted = client.decrypt_event(event)
|
||||
except Exception:
|
||||
# Still missing the key — keep in buffer.
|
||||
still_pending.append((room, event, ts))
|
||||
continue
|
||||
|
||||
if isinstance(decrypted, nio.MegolmEvent):
|
||||
# decrypt_event returned the same undecryptable event.
|
||||
still_pending.append((room, event, ts))
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Matrix: decrypted buffered event %s (%s)",
|
||||
getattr(event, "event_id", "?"),
|
||||
type(decrypted).__name__,
|
||||
)
|
||||
|
||||
# Route to the appropriate handler based on decrypted type.
|
||||
try:
|
||||
if isinstance(decrypted, nio.RoomMessageText):
|
||||
await self._on_room_message(room, decrypted)
|
||||
elif isinstance(
|
||||
decrypted,
|
||||
(nio.RoomMessageImage, nio.RoomMessageAudio,
|
||||
nio.RoomMessageVideo, nio.RoomMessageFile),
|
||||
):
|
||||
await self._on_room_message_media(room, decrypted)
|
||||
else:
|
||||
logger.debug(
|
||||
"Matrix: decrypted event %s has unhandled type %s",
|
||||
getattr(event, "event_id", "?"),
|
||||
type(decrypted).__name__,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Matrix: error processing decrypted event %s: %s",
|
||||
getattr(event, "event_id", "?"), exc,
|
||||
)
|
||||
|
||||
self._pending_megolm = still_pending
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
@@ -856,29 +699,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
|
||||
return
|
||||
|
||||
# Handle undecryptable MegolmEvents: request the missing session key
|
||||
# and buffer the event for retry once the key arrives.
|
||||
# Handle decrypted MegolmEvents — extract the inner event.
|
||||
if isinstance(event, nio.MegolmEvent):
|
||||
# Failed to decrypt.
|
||||
logger.warning(
|
||||
"Matrix: could not decrypt event %s in %s — requesting key",
|
||||
"Matrix: could not decrypt event %s in %s",
|
||||
event.event_id, room.room_id,
|
||||
)
|
||||
|
||||
# Ask other devices in the room to forward the session key.
|
||||
try:
|
||||
resp = await self._client.request_room_key(event)
|
||||
if hasattr(resp, "event_id") or not isinstance(resp, Exception):
|
||||
logger.debug(
|
||||
"Matrix: room key request sent for session %s",
|
||||
getattr(event, "session_id", "?"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: room key request failed: %s", exc)
|
||||
|
||||
# Buffer for retry on next maintenance cycle.
|
||||
self._pending_megolm.append((room, event, time.time()))
|
||||
if len(self._pending_megolm) > _MAX_PENDING_EVENTS:
|
||||
self._pending_megolm = self._pending_megolm[-_MAX_PENDING_EVENTS:]
|
||||
return
|
||||
|
||||
# Skip edits (m.replace relation).
|
||||
@@ -981,19 +808,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
event_mimetype = (content_info.get("info") or {}).get("mimetype", "")
|
||||
media_type = "application/octet-stream"
|
||||
msg_type = MessageType.DOCUMENT
|
||||
is_voice_message = False
|
||||
|
||||
if isinstance(event, nio.RoomMessageImage):
|
||||
msg_type = MessageType.PHOTO
|
||||
media_type = event_mimetype or "image/png"
|
||||
elif isinstance(event, nio.RoomMessageAudio):
|
||||
# Check for MSC3245 voice flag: org.matrix.msc3245.voice: {}
|
||||
source_content = getattr(event, "source", {}).get("content", {})
|
||||
if source_content.get("org.matrix.msc3245.voice") is not None:
|
||||
is_voice_message = True
|
||||
msg_type = MessageType.VOICE
|
||||
else:
|
||||
msg_type = MessageType.AUDIO
|
||||
msg_type = MessageType.AUDIO
|
||||
media_type = event_mimetype or "audio/ogg"
|
||||
elif isinstance(event, nio.RoomMessageVideo):
|
||||
msg_type = MessageType.VIDEO
|
||||
@@ -1031,31 +850,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if relates_to.get("rel_type") == "m.thread":
|
||||
thread_id = relates_to.get("event_id")
|
||||
|
||||
# For voice messages, cache audio locally for transcription tools.
|
||||
# Use the authenticated nio client to download (Matrix requires auth for media).
|
||||
media_urls = [http_url] if http_url else None
|
||||
media_types = [media_type] if http_url else None
|
||||
|
||||
if is_voice_message and url and url.startswith("mxc://"):
|
||||
try:
|
||||
import nio
|
||||
from gateway.platforms.base import cache_audio_from_bytes
|
||||
|
||||
resp = await self._client.download(mxc=url)
|
||||
if isinstance(resp, nio.MemoryDownloadResponse):
|
||||
# Extract extension from mimetype or default to .ogg
|
||||
ext = ".ogg"
|
||||
if media_type and "/" in media_type:
|
||||
subtype = media_type.split("/")[1]
|
||||
ext = f".{subtype}" if subtype else ".ogg"
|
||||
local_path = cache_audio_from_bytes(resp.body, ext)
|
||||
media_urls = [local_path]
|
||||
logger.debug("Matrix: cached voice message to %s", local_path)
|
||||
else:
|
||||
logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp))
|
||||
except Exception as e:
|
||||
logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e)
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=room.room_id,
|
||||
chat_type=chat_type,
|
||||
@@ -1064,9 +858,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
# Use cached local path for images (voice messages already handled above).
|
||||
if cached_path:
|
||||
media_urls = [cached_path]
|
||||
# Use cached local path for images, HTTP URL for other media types
|
||||
media_urls = [cached_path] if cached_path else ([http_url] if http_url else None)
|
||||
media_types = [media_type] if media_urls else None
|
||||
|
||||
msg_event = MessageEvent(
|
||||
|
||||
+31
-94
@@ -9,7 +9,6 @@ Uses slack-bolt (Python) with Socket Mode for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -74,10 +73,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._bot_user_id: Optional[str] = None
|
||||
self._user_name_cache: Dict[str, str] = {} # user_id → display name
|
||||
self._socket_mode_task: Optional[asyncio.Task] = None
|
||||
# Multi-workspace support
|
||||
self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
|
||||
self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
|
||||
self._channel_team: Dict[str, str] = {} # channel_id → team_id
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
@@ -87,34 +82,16 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
|
||||
raw_token = self.config.token
|
||||
bot_token = self.config.token
|
||||
app_token = os.getenv("SLACK_APP_TOKEN")
|
||||
|
||||
if not raw_token:
|
||||
if not bot_token:
|
||||
logger.error("[Slack] SLACK_BOT_TOKEN not set")
|
||||
return False
|
||||
if not app_token:
|
||||
logger.error("[Slack] SLACK_APP_TOKEN not set")
|
||||
return False
|
||||
|
||||
# Support comma-separated bot tokens for multi-workspace
|
||||
bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()]
|
||||
|
||||
# Also load tokens from OAuth token file
|
||||
from hermes_constants import get_hermes_home
|
||||
tokens_file = get_hermes_home() / "slack_tokens.json"
|
||||
if tokens_file.exists():
|
||||
try:
|
||||
saved = json.loads(tokens_file.read_text(encoding="utf-8"))
|
||||
for team_id, entry in saved.items():
|
||||
tok = entry.get("token", "") if isinstance(entry, dict) else ""
|
||||
if tok and tok not in bot_tokens:
|
||||
bot_tokens.append(tok)
|
||||
team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id
|
||||
logger.info("[Slack] Loaded saved token for workspace %s", team_label)
|
||||
except Exception as e:
|
||||
logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
|
||||
|
||||
try:
|
||||
# Acquire scoped lock to prevent duplicate app token usage
|
||||
from gateway.status import acquire_scoped_lock
|
||||
@@ -127,30 +104,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._set_fatal_error('slack_token_lock', message, retryable=False)
|
||||
return False
|
||||
|
||||
# First token is the primary — used for AsyncApp / Socket Mode
|
||||
primary_token = bot_tokens[0]
|
||||
self._app = AsyncApp(token=primary_token)
|
||||
self._app = AsyncApp(token=bot_token)
|
||||
|
||||
# Register each bot token and map team_id → client
|
||||
for token in bot_tokens:
|
||||
client = AsyncWebClient(token=token)
|
||||
auth_response = await client.auth_test()
|
||||
team_id = auth_response.get("team_id", "")
|
||||
bot_user_id = auth_response.get("user_id", "")
|
||||
bot_name = auth_response.get("user", "unknown")
|
||||
team_name = auth_response.get("team", "unknown")
|
||||
|
||||
self._team_clients[team_id] = client
|
||||
self._team_bot_user_ids[team_id] = bot_user_id
|
||||
|
||||
# First token sets the primary bot_user_id (backward compat)
|
||||
if self._bot_user_id is None:
|
||||
self._bot_user_id = bot_user_id
|
||||
|
||||
logger.info(
|
||||
"[Slack] Authenticated as @%s in workspace %s (team: %s)",
|
||||
bot_name, team_name, team_id,
|
||||
)
|
||||
# Get our own bot user ID for mention detection
|
||||
auth_response = await self._app.client.auth_test()
|
||||
self._bot_user_id = auth_response.get("user_id")
|
||||
bot_name = auth_response.get("user", "unknown")
|
||||
|
||||
# Register message event handler
|
||||
@self._app.event("message")
|
||||
@@ -175,10 +134,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
|
||||
|
||||
self._running = True
|
||||
logger.info(
|
||||
"[Slack] Socket Mode connected (%d workspace(s))",
|
||||
len(self._team_clients),
|
||||
)
|
||||
logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name)
|
||||
return True
|
||||
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
@@ -205,13 +161,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
logger.info("[Slack] Disconnected")
|
||||
|
||||
def _get_client(self, chat_id: str) -> AsyncWebClient:
|
||||
"""Return the workspace-specific WebClient for a channel."""
|
||||
team_id = self._channel_team.get(chat_id)
|
||||
if team_id and team_id in self._team_clients:
|
||||
return self._team_clients[team_id]
|
||||
return self._app.client # fallback to primary
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -248,7 +197,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if broadcast and i == 0:
|
||||
kwargs["reply_broadcast"] = True
|
||||
|
||||
last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
last_result = await self._app.client.chat_postMessage(**kwargs)
|
||||
|
||||
return SendResult(
|
||||
success=True,
|
||||
@@ -270,7 +219,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
await self._get_client(chat_id).chat_update(
|
||||
await self._app.client.chat_update(
|
||||
channel=chat_id,
|
||||
ts=message_id,
|
||||
text=content,
|
||||
@@ -304,7 +253,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return # Can only set status in a thread context
|
||||
|
||||
try:
|
||||
await self._get_client(chat_id).assistant_threads_setStatus(
|
||||
await self._app.client.assistant_threads_setStatus(
|
||||
channel_id=chat_id,
|
||||
thread_ts=thread_ts,
|
||||
status="is thinking...",
|
||||
@@ -346,7 +295,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=file_path,
|
||||
filename=os.path.basename(file_path),
|
||||
@@ -448,7 +397,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return False
|
||||
try:
|
||||
await self._get_client(channel).reactions_add(
|
||||
await self._app.client.reactions_add(
|
||||
channel=channel, timestamp=timestamp, name=emoji
|
||||
)
|
||||
return True
|
||||
@@ -464,7 +413,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return False
|
||||
try:
|
||||
await self._get_client(channel).reactions_remove(
|
||||
await self._app.client.reactions_remove(
|
||||
channel=channel, timestamp=timestamp, name=emoji
|
||||
)
|
||||
return True
|
||||
@@ -474,7 +423,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
# ----- User identity resolution -----
|
||||
|
||||
async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
|
||||
async def _resolve_user_name(self, user_id: str) -> str:
|
||||
"""Resolve a Slack user ID to a display name, with caching."""
|
||||
if not user_id:
|
||||
return ""
|
||||
@@ -485,8 +434,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return user_id
|
||||
|
||||
try:
|
||||
client = self._get_client(chat_id) if chat_id else self._app.client
|
||||
result = await client.users_info(user=user_id)
|
||||
result = await self._app.client.users_info(user=user_id)
|
||||
user = result.get("user", {})
|
||||
# Prefer display_name → real_name → user_id
|
||||
profile = user.get("profile", {})
|
||||
@@ -550,7 +498,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
content=response.content,
|
||||
filename="image.png",
|
||||
@@ -610,7 +558,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=f"Video file not found: {video_path}")
|
||||
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=video_path,
|
||||
filename=os.path.basename(video_path),
|
||||
@@ -651,7 +599,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
display_name = file_name or os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
result = await self._app.client.files_upload_v2(
|
||||
channel=chat_id,
|
||||
file=file_path,
|
||||
filename=display_name,
|
||||
@@ -679,7 +627,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return {"name": chat_id, "type": "unknown"}
|
||||
|
||||
try:
|
||||
result = await self._get_client(chat_id).conversations_info(channel=chat_id)
|
||||
result = await self._app.client.conversations_info(channel=chat_id)
|
||||
channel = result.get("channel", {})
|
||||
is_dm = channel.get("is_im", False)
|
||||
return {
|
||||
@@ -712,11 +660,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
user_id = event.get("user", "")
|
||||
channel_id = event.get("channel", "")
|
||||
ts = event.get("ts", "")
|
||||
team_id = event.get("team", "")
|
||||
|
||||
# Track which workspace owns this channel
|
||||
if team_id and channel_id:
|
||||
self._channel_team[channel_id] = team_id
|
||||
|
||||
# Determine if this is a DM or channel message
|
||||
channel_type = event.get("channel_type", "")
|
||||
@@ -733,12 +676,11 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
|
||||
|
||||
# In channels, only respond if bot is mentioned
|
||||
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||
if not is_dm and bot_uid:
|
||||
if f"<@{bot_uid}>" not in text:
|
||||
if not is_dm and self._bot_user_id:
|
||||
if f"<@{self._bot_user_id}>" not in text:
|
||||
return
|
||||
# Strip the bot mention from the text
|
||||
text = text.replace(f"<@{bot_uid}>", "").strip()
|
||||
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
@@ -758,7 +700,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
|
||||
ext = ".jpg"
|
||||
# Slack private URLs require the bot token as auth header
|
||||
cached = await self._download_slack_file(url, ext, team_id=team_id)
|
||||
cached = await self._download_slack_file(url, ext)
|
||||
media_urls.append(cached)
|
||||
media_types.append(mimetype)
|
||||
msg_type = MessageType.PHOTO
|
||||
@@ -769,7 +711,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
ext = "." + mimetype.split("/")[-1].split(";")[0]
|
||||
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
|
||||
ext = ".ogg"
|
||||
cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id)
|
||||
cached = await self._download_slack_file(url, ext, audio=True)
|
||||
media_urls.append(cached)
|
||||
media_types.append(mimetype)
|
||||
msg_type = MessageType.VOICE
|
||||
@@ -800,7 +742,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
continue
|
||||
|
||||
# Download and cache
|
||||
raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id)
|
||||
raw_bytes = await self._download_slack_file_bytes(url)
|
||||
cached_path = cache_document_from_bytes(
|
||||
raw_bytes, original_filename or f"document{ext}"
|
||||
)
|
||||
@@ -829,7 +771,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
|
||||
|
||||
# Resolve user display name (cached after first lookup)
|
||||
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
|
||||
user_name = await self._resolve_user_name(user_id)
|
||||
|
||||
# Build source
|
||||
source = self.build_source(
|
||||
@@ -866,11 +808,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
text = command.get("text", "").strip()
|
||||
user_id = command.get("user_id", "")
|
||||
channel_id = command.get("channel_id", "")
|
||||
team_id = command.get("team_id", "")
|
||||
|
||||
# Track which workspace owns this channel
|
||||
if team_id and channel_id:
|
||||
self._channel_team[channel_id] = team_id
|
||||
|
||||
# Map subcommands to gateway commands — derived from central registry.
|
||||
# Also keep "compact" as a Slack-specific alias for /compress.
|
||||
@@ -902,12 +839,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
|
||||
async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str:
|
||||
"""Download a Slack file using the bot token for auth, with retry."""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
|
||||
bot_token = self.config.token
|
||||
last_exc = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
@@ -937,12 +874,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
raise
|
||||
raise last_exc
|
||||
|
||||
async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes:
|
||||
async def _download_slack_file_bytes(self, url: str) -> bytes:
|
||||
"""Download a Slack file and return raw bytes, with retry."""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
|
||||
bot_token = self.config.token
|
||||
last_exc = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
|
||||
+24
-240
@@ -8,7 +8,6 @@ Uses python-telegram-bot library for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -123,8 +122,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
super().__init__(config, Platform.TELEGRAM)
|
||||
self._app: Optional[Application] = None
|
||||
self._bot: Optional[Bot] = None
|
||||
self._webhook_mode: bool = False
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
# Buffer rapid/album photo updates so Telegram image bursts are handled
|
||||
# as a single MessageEvent instead of self-interrupting multiple turns.
|
||||
@@ -459,19 +456,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Telegram via polling or webhook.
|
||||
|
||||
By default, uses long polling (outbound connection to Telegram).
|
||||
If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server
|
||||
instead. Webhook mode is useful for cloud deployments (Fly.io,
|
||||
Railway) where inbound HTTP can wake a suspended machine.
|
||||
|
||||
Env vars for webhook mode::
|
||||
|
||||
TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
|
||||
TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
|
||||
TELEGRAM_WEBHOOK_SECRET Secret token for update verification
|
||||
"""
|
||||
"""Connect to Telegram and start polling for updates."""
|
||||
if not TELEGRAM_AVAILABLE:
|
||||
logger.error(
|
||||
"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
|
||||
@@ -565,76 +550,37 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
raise
|
||||
await self._app.start()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Decide between webhook and polling mode
|
||||
webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
|
||||
def _polling_error_callback(error: Exception) -> None:
|
||||
if self._polling_error_task and not self._polling_error_task.done():
|
||||
return
|
||||
if self._looks_like_polling_conflict(error):
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
|
||||
elif self._looks_like_network_error(error):
|
||||
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
|
||||
else:
|
||||
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
|
||||
|
||||
if webhook_url:
|
||||
# ── Webhook mode ─────────────────────────────────────
|
||||
# Telegram pushes updates to our HTTP endpoint. This
|
||||
# enables cloud platforms (Fly.io, Railway) to auto-wake
|
||||
# suspended machines on inbound HTTP traffic.
|
||||
webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443"))
|
||||
webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None
|
||||
from urllib.parse import urlparse
|
||||
webhook_path = urlparse(webhook_url).path or "/telegram"
|
||||
# Store reference for retry use in _handle_polling_conflict
|
||||
self._polling_error_callback_ref = _polling_error_callback
|
||||
|
||||
await self._app.updater.start_webhook(
|
||||
listen="0.0.0.0",
|
||||
port=webhook_port,
|
||||
url_path=webhook_path,
|
||||
webhook_url=webhook_url,
|
||||
secret_token=webhook_secret,
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
self._webhook_mode = True
|
||||
logger.info(
|
||||
"[%s] Webhook server listening on 0.0.0.0:%d%s",
|
||||
self.name, webhook_port, webhook_path,
|
||||
)
|
||||
else:
|
||||
# ── Polling mode (default) ───────────────────────────
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _polling_error_callback(error: Exception) -> None:
|
||||
if self._polling_error_task and not self._polling_error_task.done():
|
||||
return
|
||||
if self._looks_like_polling_conflict(error):
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
|
||||
elif self._looks_like_network_error(error):
|
||||
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
|
||||
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
|
||||
else:
|
||||
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
|
||||
|
||||
# Store reference for retry use in _handle_polling_conflict
|
||||
self._polling_error_callback_ref = _polling_error_callback
|
||||
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
error_callback=_polling_error_callback,
|
||||
)
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
error_callback=_polling_error_callback,
|
||||
)
|
||||
|
||||
# Register bot commands so Telegram shows a hint menu when users type /
|
||||
# List is derived from the central COMMAND_REGISTRY — adding a new
|
||||
# gateway command there automatically adds it to the Telegram menu.
|
||||
try:
|
||||
from telegram import BotCommand
|
||||
from hermes_cli.commands import telegram_menu_commands
|
||||
# Telegram allows up to 100 commands but has an undocumented
|
||||
# payload size limit. Skill descriptions are truncated to 40
|
||||
# chars in telegram_menu_commands() to fit 100 commands safely.
|
||||
menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
|
||||
from hermes_cli.commands import telegram_bot_commands
|
||||
await self._bot.set_my_commands([
|
||||
BotCommand(name, desc) for name, desc in menu_commands
|
||||
BotCommand(name, desc) for name, desc in telegram_bot_commands()
|
||||
])
|
||||
if hidden_count:
|
||||
logger.info(
|
||||
"[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
|
||||
self.name, len(menu_commands), hidden_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Could not register Telegram command menu: %s",
|
||||
@@ -644,8 +590,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
self._mark_connected()
|
||||
mode = "webhook" if self._webhook_mode else "polling"
|
||||
logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
|
||||
logger.info("[%s] Connected and polling for Telegram updates", self.name)
|
||||
|
||||
# Set up DM topics (Bot API 9.4 — Private Chat Topics)
|
||||
# Runs after connection is established so the bot can call createForumTopic.
|
||||
@@ -673,7 +618,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
|
||||
"""Stop polling, cancel pending album flushes, and disconnect."""
|
||||
pending_media_group_tasks = list(self._media_group_tasks.values())
|
||||
for task in pending_media_group_tasks:
|
||||
task.cancel()
|
||||
@@ -817,16 +762,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
effective_thread_id = None
|
||||
continue
|
||||
if "message to be replied not found" in err_lower and reply_to_id is not None:
|
||||
# Original message was deleted before we
|
||||
# could reply — clear reply target and retry
|
||||
# so the response is still delivered.
|
||||
logger.warning(
|
||||
"[%s] Reply target deleted, retrying without reply_to: %s",
|
||||
self.name, send_err,
|
||||
)
|
||||
reply_to_id = None
|
||||
continue
|
||||
# Other BadRequest errors are permanent — don't retry
|
||||
raise
|
||||
if _send_attempt < 2:
|
||||
@@ -1380,148 +1315,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
return text
|
||||
|
||||
# ── Group mention gating ──────────────────────────────────────────────
|
||||
|
||||
def _telegram_require_mention(self) -> bool:
|
||||
"""Return whether group chats should require an explicit bot trigger."""
|
||||
configured = self.config.extra.get("require_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() in ("true", "1", "yes", "on")
|
||||
return bool(configured)
|
||||
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _telegram_free_response_chats(self) -> set[str]:
|
||||
raw = self.config.extra.get("free_response_chats")
|
||||
if raw is None:
|
||||
raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
||||
"""Compile optional regex wake-word patterns for group triggers."""
|
||||
patterns = self.config.extra.get("mention_patterns")
|
||||
if patterns is None:
|
||||
raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
loaded = json.loads(raw)
|
||||
except Exception:
|
||||
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
|
||||
if not loaded:
|
||||
loaded = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
patterns = loaded
|
||||
|
||||
if patterns is None:
|
||||
return []
|
||||
if isinstance(patterns, str):
|
||||
patterns = [patterns]
|
||||
if not isinstance(patterns, list):
|
||||
logger.warning(
|
||||
"[%s] telegram mention_patterns must be a list or string; got %s",
|
||||
self.name,
|
||||
type(patterns).__name__,
|
||||
)
|
||||
return []
|
||||
|
||||
compiled: List[re.Pattern] = []
|
||||
for pattern in patterns:
|
||||
if not isinstance(pattern, str) or not pattern.strip():
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(pattern, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
|
||||
if compiled:
|
||||
logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
|
||||
return compiled
|
||||
|
||||
def _is_group_chat(self, message: Message) -> bool:
|
||||
chat = getattr(message, "chat", None)
|
||||
if not chat:
|
||||
return False
|
||||
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
||||
return chat_type in ("group", "supergroup")
|
||||
|
||||
def _is_reply_to_bot(self, message: Message) -> bool:
|
||||
if not self._bot or not getattr(message, "reply_to_message", None):
|
||||
return False
|
||||
reply_user = getattr(message.reply_to_message, "from_user", None)
|
||||
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
|
||||
|
||||
def _message_mentions_bot(self, message: Message) -> bool:
|
||||
if not self._bot:
|
||||
return False
|
||||
|
||||
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
||||
bot_id = getattr(self._bot, "id", None)
|
||||
|
||||
def _iter_sources():
|
||||
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
||||
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
||||
|
||||
for source_text, entities in _iter_sources():
|
||||
if bot_username and f"@{bot_username}" in source_text.lower():
|
||||
return True
|
||||
for entity in entities:
|
||||
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
||||
if entity_type == "mention" and bot_username:
|
||||
offset = int(getattr(entity, "offset", -1))
|
||||
length = int(getattr(entity, "length", 0))
|
||||
if offset < 0 or length <= 0:
|
||||
continue
|
||||
if source_text[offset:offset + length].strip().lower() == f"@{bot_username}":
|
||||
return True
|
||||
elif entity_type == "text_mention":
|
||||
user = getattr(entity, "user", None)
|
||||
if user and getattr(user, "id", None) == bot_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
||||
if not self._mention_patterns:
|
||||
return False
|
||||
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
|
||||
if not candidate:
|
||||
continue
|
||||
for pattern in self._mention_patterns:
|
||||
if pattern.search(candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
|
||||
if not text or not self._bot or not getattr(self._bot, "username", None):
|
||||
return text
|
||||
username = re.escape(self._bot.username)
|
||||
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
|
||||
return cleaned or text
|
||||
|
||||
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
||||
"""Apply Telegram group trigger rules.
|
||||
|
||||
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
||||
- the chat is explicitly allowlisted in ``free_response_chats``
|
||||
- ``require_mention`` is disabled
|
||||
- the message is a command
|
||||
- the message replies to the bot
|
||||
- the bot is @mentioned
|
||||
- the text/caption matches a configured regex wake-word pattern
|
||||
"""
|
||||
if not self._is_group_chat(message):
|
||||
return True
|
||||
if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
|
||||
return True
|
||||
if not self._telegram_require_mention():
|
||||
return True
|
||||
if is_command:
|
||||
return True
|
||||
if self._is_reply_to_bot(message):
|
||||
return True
|
||||
if self._message_mentions_bot(message):
|
||||
return True
|
||||
return self._message_matches_mention_patterns(message)
|
||||
|
||||
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming text messages.
|
||||
|
||||
@@ -1531,19 +1324,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.TEXT)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming command messages."""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
if not self._should_process_message(update.message, is_command=True):
|
||||
return
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.COMMAND)
|
||||
await self.handle_message(event)
|
||||
@@ -1552,8 +1340,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""Handle incoming location/venue pin messages."""
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
venue = getattr(msg, "venue", None)
|
||||
@@ -1697,8 +1483,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""Handle incoming media messages, downloading images to local cache."""
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
|
||||
@@ -1722,7 +1506,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
# Add caption as text
|
||||
if msg.caption:
|
||||
event.text = self._clean_bot_trigger_text(msg.caption)
|
||||
event.text = msg.caption
|
||||
|
||||
# Handle stickers: describe via vision tool with caching
|
||||
if msg.sticker:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+13
-306
@@ -225,49 +225,6 @@ from gateway.session import (
|
||||
from gateway.delivery import DeliveryRouter
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
|
||||
|
||||
def _normalize_whatsapp_identifier(value: str) -> str:
|
||||
"""Strip WhatsApp JID/LID syntax down to its stable numeric identifier."""
|
||||
return (
|
||||
str(value or "")
|
||||
.strip()
|
||||
.replace("+", "", 1)
|
||||
.split(":", 1)[0]
|
||||
.split("@", 1)[0]
|
||||
)
|
||||
|
||||
|
||||
def _expand_whatsapp_auth_aliases(identifier: str) -> set:
|
||||
"""Resolve WhatsApp phone/LID aliases using bridge session mapping files."""
|
||||
normalized = _normalize_whatsapp_identifier(identifier)
|
||||
if not normalized:
|
||||
return set()
|
||||
|
||||
session_dir = _hermes_home / "whatsapp" / "session"
|
||||
resolved = set()
|
||||
queue = [normalized]
|
||||
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if not current or current in resolved:
|
||||
continue
|
||||
|
||||
resolved.add(current)
|
||||
for suffix in ("", "_reverse"):
|
||||
mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json"
|
||||
if not mapping_path.exists():
|
||||
continue
|
||||
try:
|
||||
mapped = _normalize_whatsapp_identifier(
|
||||
json.loads(mapping_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if mapped and mapped not in resolved:
|
||||
queue.append(mapped)
|
||||
|
||||
return resolved
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel placed into _running_agents immediately when a session starts
|
||||
@@ -301,50 +258,6 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
"""Check if a command matches a known-but-inactive skill.
|
||||
|
||||
Returns a helpful message if the skill exists but is disabled or only
|
||||
available as an optional install. Returns None if no match found.
|
||||
"""
|
||||
# Normalize: command uses hyphens, skill names may use hyphens or underscores
|
||||
normalized = command_name.lower().replace("_", "-")
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names
|
||||
disabled = _get_disabled_skill_names()
|
||||
|
||||
# Check disabled built-in skills
|
||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
continue
|
||||
name = skill_md.parent.name.lower().replace("_", "-")
|
||||
if name == normalized and name in disabled:
|
||||
return (
|
||||
f"The **{command_name}** skill is installed but disabled.\n"
|
||||
f"Enable it with: `hermes skills config`"
|
||||
)
|
||||
|
||||
# Check optional skills (shipped with repo but not installed)
|
||||
from hermes_constants import get_hermes_home, get_optional_skills_dir
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
|
||||
if optional_dir.exists():
|
||||
for skill_md in optional_dir.rglob("SKILL.md"):
|
||||
name = skill_md.parent.name.lower().replace("_", "-")
|
||||
if name == normalized:
|
||||
# Build install path: official/<category>/<name>
|
||||
rel = skill_md.parent.relative_to(optional_dir)
|
||||
parts = list(rel.parts)
|
||||
install_path = f"official/{'/'.join(parts)}"
|
||||
return (
|
||||
f"The **{command_name}** skill is available but not installed.\n"
|
||||
f"Install it with: `hermes skills install {install_path}`"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _platform_config_key(platform: "Platform") -> str:
|
||||
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
|
||||
return "cli" if platform == Platform.LOCAL else platform.value
|
||||
@@ -367,10 +280,10 @@ def _resolve_gateway_model(config: dict | None = None) -> str:
|
||||
"""Read model from env/config — mirrors the resolution in _run_agent_sync.
|
||||
|
||||
Without this, temporary AIAgent instances (memory flush, /compress) fall
|
||||
back to the hardcoded default which fails when the active provider is
|
||||
openai-codex.
|
||||
back to the hardcoded default ("anthropic/claude-opus-4.6") which fails
|
||||
when the active provider is openai-codex.
|
||||
"""
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or ""
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
|
||||
cfg = config if config is not None else _load_gateway_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
@@ -476,13 +389,6 @@ class GatewayRunner:
|
||||
self._honcho_managers: Dict[str, Any] = {}
|
||||
self._honcho_configs: Dict[str, Any] = {}
|
||||
|
||||
# Rate-limit compression warning messages sent to users.
|
||||
# Keyed by chat_id — value is the timestamp of the last warning sent.
|
||||
# Prevents the warning from firing on every message when a session
|
||||
# remains above the threshold after compression.
|
||||
self._compression_warn_sent: Dict[str, float] = {}
|
||||
self._compression_warn_cooldown: int = 3600 # seconds (1 hour)
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
@@ -1077,8 +983,6 @@ class GatewayRunner:
|
||||
"EMAIL_ALLOWED_USERS",
|
||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
||||
"FEISHU_ALLOWED_USERS",
|
||||
"WECOM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS")
|
||||
)
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||
@@ -1087,9 +991,7 @@ class GatewayRunner:
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
|
||||
"FEISHU_ALLOW_ALL_USERS",
|
||||
"WECOM_ALLOW_ALL_USERS")
|
||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
|
||||
)
|
||||
if not _any_allowlist and not _allow_all:
|
||||
logger.warning(
|
||||
@@ -1532,20 +1434,6 @@ class GatewayRunner:
|
||||
return None
|
||||
return DingTalkAdapter(config)
|
||||
|
||||
elif platform == Platform.FEISHU:
|
||||
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
|
||||
if not check_feishu_requirements():
|
||||
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
|
||||
return None
|
||||
return FeishuAdapter(config)
|
||||
|
||||
elif platform == Platform.WECOM:
|
||||
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
|
||||
if not check_wecom_requirements():
|
||||
logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
|
||||
return None
|
||||
return WeComAdapter(config)
|
||||
|
||||
elif platform == Platform.MATTERMOST:
|
||||
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
|
||||
if not check_mattermost_requirements():
|
||||
@@ -1612,8 +1500,6 @@ class GatewayRunner:
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
}
|
||||
platform_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
@@ -1626,8 +1512,6 @@ class GatewayRunner:
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
|
||||
}
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
@@ -1655,23 +1539,10 @@ class GatewayRunner:
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
# WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison
|
||||
check_ids = {user_id}
|
||||
if "@" in user_id:
|
||||
check_ids.add(user_id.split("@")[0])
|
||||
|
||||
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
|
||||
if source.platform == Platform.WHATSAPP:
|
||||
normalized_allowed_ids = set()
|
||||
for allowed_id in allowed_ids:
|
||||
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
|
||||
if normalized_allowed_ids:
|
||||
allowed_ids = normalized_allowed_ids
|
||||
|
||||
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
|
||||
normalized_user_id = _normalize_whatsapp_identifier(user_id)
|
||||
if normalized_user_id:
|
||||
check_ids.add(normalized_user_id)
|
||||
|
||||
return bool(check_ids & allowed_ids)
|
||||
|
||||
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
|
||||
@@ -1702,11 +1573,6 @@ class GatewayRunner:
|
||||
# In DMs: offer pairing code. In groups: silently ignore.
|
||||
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
|
||||
platform_name = source.platform.value if source.platform else "unknown"
|
||||
# Rate-limit ALL pairing responses (code or rejection) to
|
||||
# prevent spamming the user with repeated messages when
|
||||
# multiple DMs arrive in quick succession.
|
||||
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
|
||||
return None
|
||||
code = self.pairing_store.generate_code(
|
||||
platform_name, source.user_id, source.user_name or ""
|
||||
)
|
||||
@@ -1728,8 +1594,6 @@ class GatewayRunner:
|
||||
"Too many pairing requests right now~ "
|
||||
"Please try again later!"
|
||||
)
|
||||
# Record rate limit so subsequent messages are silently ignored
|
||||
self.pairing_store._record_rate_limit(platform_name, source.user_id)
|
||||
return None
|
||||
|
||||
# PRIORITY handling when an agent is already running for this session.
|
||||
@@ -1875,13 +1739,7 @@ class GatewayRunner:
|
||||
|
||||
if canonical == "help":
|
||||
return await self._handle_help_command(event)
|
||||
|
||||
if canonical == "commands":
|
||||
return await self._handle_commands_command(event)
|
||||
|
||||
if canonical == "profile":
|
||||
return await self._handle_profile_command(event)
|
||||
|
||||
if canonical == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
@@ -1894,9 +1752,6 @@ class GatewayRunner:
|
||||
if canonical == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
if canonical == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
|
||||
if canonical == "provider":
|
||||
return await self._handle_provider_command(event)
|
||||
|
||||
@@ -2041,12 +1896,6 @@ class GatewayRunner:
|
||||
if msg:
|
||||
event.text = msg
|
||||
# Fall through to normal message processing with skill content
|
||||
else:
|
||||
# Not an active skill — check if it's a known-but-disabled or
|
||||
# uninstalled skill and give actionable guidance.
|
||||
_unavail_msg = _check_unavailable_skill(command)
|
||||
if _unavail_msg:
|
||||
return _unavail_msg
|
||||
except Exception as e:
|
||||
logger.debug("Skill command check failed (non-fatal): %s", e)
|
||||
|
||||
@@ -2284,29 +2133,6 @@ class GatewayRunner:
|
||||
_hyg_api_key = _hyg_runtime.get("api_key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check custom_providers per-model context_length
|
||||
# (same fallback as run_agent.py lines 1171-1189).
|
||||
# Must run after runtime resolution so _hyg_base_url is set.
|
||||
if _hyg_config_context_length is None and _hyg_base_url:
|
||||
try:
|
||||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||||
if isinstance(_hyg_custom_providers, list):
|
||||
for _cp in _hyg_custom_providers:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_url = (_cp.get("base_url") or "").rstrip("/")
|
||||
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
|
||||
_cp_models = _cp.get("models", {})
|
||||
if isinstance(_cp_models, dict):
|
||||
_cp_model_cfg = _cp_models.get(_hyg_model, {})
|
||||
if isinstance(_cp_model_cfg, dict):
|
||||
_cp_ctx = _cp_model_cfg.get("context_length")
|
||||
if _cp_ctx is not None:
|
||||
_hyg_config_context_length = int(_cp_ctx)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2440,18 +2266,13 @@ class GatewayRunner:
|
||||
pass
|
||||
|
||||
# Still too large after compression — warn user
|
||||
# Rate-limited to once per cooldown period per
|
||||
# chat to avoid spamming on every message.
|
||||
if _new_tokens >= _warn_token_threshold:
|
||||
logger.warning(
|
||||
"Session hygiene: still ~%s tokens after "
|
||||
"compression — suggesting /reset",
|
||||
f"{_new_tokens:,}",
|
||||
)
|
||||
_now = time.time()
|
||||
_last_warn = self._compression_warn_sent.get(source.chat_id, 0)
|
||||
if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown:
|
||||
self._compression_warn_sent[source.chat_id] = _now
|
||||
if _hyg_adapter:
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
@@ -2473,10 +2294,7 @@ class GatewayRunner:
|
||||
if _approx_tokens >= _warn_token_threshold:
|
||||
_hyg_adapter = self.adapters.get(source.platform)
|
||||
_hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
_now = time.time()
|
||||
_last_warn = self._compression_warn_sent.get(source.chat_id, 0)
|
||||
if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown:
|
||||
self._compression_warn_sent[source.chat_id] = _now
|
||||
if _hyg_adapter:
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
@@ -3103,36 +2921,6 @@ class GatewayRunner:
|
||||
return f"{header}\n\n{session_info}"
|
||||
return header
|
||||
|
||||
async def _handle_profile_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /profile — show active profile name and home directory."""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from pathlib import Path
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
# Detect profile name from HERMES_HOME path
|
||||
# Profile paths look like: ~/.hermes/profiles/<name>
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
if profile_name:
|
||||
lines = [
|
||||
f"👤 **Profile:** `{profile_name}`",
|
||||
f"📂 **Home:** `{display}`",
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
"👤 **Profile:** default",
|
||||
f"📂 **Home:** `{display}`",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /status command."""
|
||||
source = event.source
|
||||
@@ -3199,68 +2987,11 @@ class GatewayRunner:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
if skill_cmds:
|
||||
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):")
|
||||
# Show first 10, then point to /commands for the rest
|
||||
sorted_cmds = sorted(skill_cmds)
|
||||
for cmd in sorted_cmds[:10]:
|
||||
lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
|
||||
if len(sorted_cmds) > 10:
|
||||
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_commands_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /commands [page] - paginated list of all commands and skills."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
if raw_args:
|
||||
try:
|
||||
requested_page = int(raw_args)
|
||||
except ValueError:
|
||||
return "Usage: `/commands [page]`"
|
||||
else:
|
||||
requested_page = 1
|
||||
|
||||
# Build combined entry list: built-in commands + skill commands
|
||||
entries = list(gateway_help_lines())
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
if skill_cmds:
|
||||
entries.append("")
|
||||
entries.append("⚡ **Skill Commands**:")
|
||||
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):")
|
||||
for cmd in sorted(skill_cmds):
|
||||
desc = skill_cmds[cmd].get("description", "").strip() or "Skill command"
|
||||
entries.append(f"`{cmd}` — {desc}")
|
||||
lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not entries:
|
||||
return "No commands available."
|
||||
|
||||
from gateway.config import Platform
|
||||
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
|
||||
total_pages = max(1, (len(entries) + page_size - 1) // page_size)
|
||||
page = max(1, min(requested_page, total_pages))
|
||||
start = (page - 1) * page_size
|
||||
page_entries = entries[start:start + page_size]
|
||||
|
||||
lines = [
|
||||
f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})",
|
||||
"",
|
||||
*page_entries,
|
||||
]
|
||||
if total_pages > 1:
|
||||
nav_parts = []
|
||||
if page > 1:
|
||||
nav_parts.append(f"`/commands {page - 1}` ← prev")
|
||||
if page < total_pages:
|
||||
nav_parts.append(f"next → `/commands {page + 1}`")
|
||||
lines.extend(["", " | ".join(nav_parts)])
|
||||
if page != requested_page:
|
||||
lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_provider_command(self, event: MessageEvent) -> str:
|
||||
@@ -4082,7 +3813,7 @@ class GatewayRunner:
|
||||
# Send media files
|
||||
for media_path in (media_files or []):
|
||||
try:
|
||||
await adapter.send_document(
|
||||
await adapter.send_file(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
)
|
||||
@@ -4190,16 +3921,6 @@ class GatewayRunner:
|
||||
else:
|
||||
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass."""
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
return "⚠️ YOLO mode **OFF** — dangerous commands will require approval."
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution."
|
||||
|
||||
async def _handle_verbose_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /verbose command — cycle tool progress display mode.
|
||||
|
||||
@@ -4695,10 +4416,6 @@ class GatewayRunner:
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from hermes_cli.config import is_managed, format_managed_message
|
||||
|
||||
if is_managed():
|
||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
git_dir = project_root / '.git'
|
||||
@@ -5224,14 +4941,6 @@ class GatewayRunner:
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
|
||||
|
||||
# Apply tool preview length config (0 = no limit)
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = user_config.get("display", {}).get("tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
|
||||
# Falls back to env vars for backward compatibility.
|
||||
# YAML 1.1 parses bare `off` as boolean False — normalise before
|
||||
@@ -5277,11 +4986,9 @@ class GatewayRunner:
|
||||
return
|
||||
|
||||
if preview:
|
||||
# Truncate preview unless config says unlimited
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(preview) > _pl:
|
||||
preview = preview[:_pl - 3] + "..."
|
||||
# Truncate preview to keep messages clean
|
||||
if len(preview) > 80:
|
||||
preview = preview[:77] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hermes Agent CLI launcher.
|
||||
Hermes Agent CLI Launcher
|
||||
|
||||
This wrapper should behave like the installed `hermes` command, including
|
||||
subcommands such as `gateway`, `cron`, and `doctor`.
|
||||
This is a convenience wrapper to launch the Hermes CLI.
|
||||
Usage: ./hermes [options]
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from hermes_cli.main import main
|
||||
main()
|
||||
from cli import main
|
||||
import fire
|
||||
fire.Fire(main)
|
||||
|
||||
@@ -11,5 +11,5 @@ Provides subcommands for:
|
||||
- hermes cron - Manage cron jobs
|
||||
"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__release_date__ = "2026.3.30"
|
||||
__version__ = "0.5.0"
|
||||
__release_date__ = "2026.3.28"
|
||||
|
||||
+8
-16
@@ -696,10 +696,6 @@ def resolve_provider(
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||||
# Local server aliases — route through the generic custom provider
|
||||
"lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
|
||||
"ollama": "custom", "vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
}
|
||||
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
|
||||
|
||||
@@ -746,12 +742,7 @@ def resolve_provider(
|
||||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
return pid
|
||||
|
||||
raise AuthError(
|
||||
"No inference provider configured. Run 'hermes model' to choose a "
|
||||
"provider and model, or set an API key (OPENROUTER_API_KEY, "
|
||||
"OPENAI_API_KEY, etc.) in ~/.hermes/.env.",
|
||||
code="no_provider_configured",
|
||||
)
|
||||
return "openrouter"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -2310,20 +2301,21 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
raise AuthError("No runtime API key available to fetch models",
|
||||
provider="nous", code="invalid_token")
|
||||
|
||||
# Use curated model list (same as OpenRouter defaults) instead
|
||||
# of the full /models dump which returns hundreds of models.
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
||||
model_ids = fetch_nous_models(
|
||||
inference_base_url=runtime_base_url,
|
||||
api_key=runtime_key,
|
||||
timeout_seconds=timeout_seconds,
|
||||
verify=verify,
|
||||
)
|
||||
|
||||
print()
|
||||
if model_ids:
|
||||
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
|
||||
selected_model = _prompt_model_selection(model_ids)
|
||||
if selected_model:
|
||||
_save_model_choice(selected_model)
|
||||
print(f"Default model set to: {selected_model}")
|
||||
else:
|
||||
print("No curated models available for Nous Portal.")
|
||||
print("No models were returned by the inference API.")
|
||||
except Exception as exc:
|
||||
message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
|
||||
print()
|
||||
|
||||
@@ -432,11 +432,10 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
try:
|
||||
behind = get_update_result(timeout=0.5)
|
||||
if behind and behind > 0:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
f"[dim yellow] — run [bold]hermes update[/bold] to update[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
@@ -241,8 +241,7 @@ def approval_callback(cli, command: str, description: str) -> str:
|
||||
lock = cli._approval_lock
|
||||
|
||||
with lock:
|
||||
from cli import CLI_CONFIG
|
||||
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
if len(command) > 70:
|
||||
|
||||
@@ -5,7 +5,6 @@ toggleable list of items. Falls back to a numbered text UI when
|
||||
curses is unavailable (Windows without curses, piped stdin, etc.).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import List, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -27,10 +26,6 @@ def curses_checklist(
|
||||
The indices the user confirmed as checked. On cancel (ESC/q),
|
||||
returns ``pre_selected`` unchanged.
|
||||
"""
|
||||
# Safety: return defaults when stdin is not a terminal.
|
||||
if not sys.stdin.isatty():
|
||||
return set(pre_selected)
|
||||
|
||||
try:
|
||||
import curses
|
||||
selected = set(pre_selected)
|
||||
|
||||
+5
-265
@@ -4,19 +4,14 @@ Usage:
|
||||
hermes claw migrate # Interactive migration from ~/.openclaw
|
||||
hermes claw migrate --dry-run # Preview what would be migrated
|
||||
hermes claw migrate --preset full --overwrite # Full migration, overwrite conflicts
|
||||
hermes claw cleanup # Archive leftover OpenClaw directories
|
||||
hermes claw cleanup --dry-run # Preview what would be archived
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
from hermes_cli.setup import (
|
||||
Colors,
|
||||
color,
|
||||
@@ -24,7 +19,6 @@ from hermes_cli.setup import (
|
||||
print_info,
|
||||
print_success,
|
||||
print_error,
|
||||
print_warning,
|
||||
prompt_yes_no,
|
||||
)
|
||||
|
||||
@@ -33,7 +27,8 @@ logger = logging.getLogger(__name__)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
_OPENCLAW_SCRIPT = (
|
||||
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||
PROJECT_ROOT
|
||||
/ "optional-skills"
|
||||
/ "migration"
|
||||
/ "openclaw-migration"
|
||||
/ "scripts"
|
||||
@@ -50,18 +45,6 @@ _OPENCLAW_SCRIPT_INSTALLED = (
|
||||
/ "openclaw_to_hermes.py"
|
||||
)
|
||||
|
||||
# Known OpenClaw directory names (current + legacy)
|
||||
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot")
|
||||
|
||||
# State files commonly found in OpenClaw workspace directories that cause
|
||||
# confusion after migration (the agent discovers them and writes to them)
|
||||
_WORKSPACE_STATE_GLOBS = (
|
||||
"*/todo.json",
|
||||
"*/sessions/*",
|
||||
"*/memory/*.json",
|
||||
"*/logs/*",
|
||||
)
|
||||
|
||||
|
||||
def _find_migration_script() -> Path | None:
|
||||
"""Find the openclaw_to_hermes.py script in known locations."""
|
||||
@@ -88,105 +71,24 @@ def _load_migration_module(script_path: Path):
|
||||
return mod
|
||||
|
||||
|
||||
def _find_openclaw_dirs() -> list[Path]:
|
||||
"""Find all OpenClaw directories on disk."""
|
||||
found = []
|
||||
for name in _OPENCLAW_DIR_NAMES:
|
||||
candidate = Path.home() / name
|
||||
if candidate.is_dir():
|
||||
found.append(candidate)
|
||||
return found
|
||||
|
||||
|
||||
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""Scan an OpenClaw directory for workspace state files that cause confusion.
|
||||
|
||||
Returns a list of (path, description) tuples.
|
||||
"""
|
||||
findings: list[tuple[Path, str]] = []
|
||||
|
||||
# Direct state files in the root
|
||||
for name in ("todo.json", "sessions", "logs"):
|
||||
candidate = source_dir / name
|
||||
if candidate.exists():
|
||||
kind = "directory" if candidate.is_dir() else "file"
|
||||
findings.append((candidate, f"Root {kind}: {name}"))
|
||||
|
||||
# State files inside workspace directories
|
||||
for child in sorted(source_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
# Check for workspace-like subdirectories
|
||||
for state_name in ("todo.json", "sessions", "logs", "memory"):
|
||||
state_path = child / state_name
|
||||
if state_path.exists():
|
||||
kind = "directory" if state_path.is_dir() else "file"
|
||||
rel = state_path.relative_to(source_dir)
|
||||
findings.append((state_path, f"Workspace {kind}: {rel}"))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path:
|
||||
"""Rename an OpenClaw directory to .pre-migration.
|
||||
|
||||
Returns the archive path.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d")
|
||||
archive_name = f"{source_dir.name}.pre-migration"
|
||||
archive_path = source_dir.parent / archive_name
|
||||
|
||||
# If archive already exists, add timestamp
|
||||
if archive_path.exists():
|
||||
archive_name = f"{source_dir.name}.pre-migration-{timestamp}"
|
||||
archive_path = source_dir.parent / archive_name
|
||||
|
||||
# If still exists (multiple runs same day), add counter
|
||||
counter = 2
|
||||
while archive_path.exists():
|
||||
archive_name = f"{source_dir.name}.pre-migration-{timestamp}-{counter}"
|
||||
archive_path = source_dir.parent / archive_name
|
||||
counter += 1
|
||||
|
||||
if not dry_run:
|
||||
source_dir.rename(archive_path)
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def claw_command(args):
|
||||
"""Route hermes claw subcommands."""
|
||||
action = getattr(args, "claw_action", None)
|
||||
|
||||
if action == "migrate":
|
||||
_cmd_migrate(args)
|
||||
elif action in ("cleanup", "clean"):
|
||||
_cmd_cleanup(args)
|
||||
else:
|
||||
print("Usage: hermes claw <command> [options]")
|
||||
print("Usage: hermes claw migrate [options]")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" migrate Migrate settings from OpenClaw to Hermes")
|
||||
print(" cleanup Archive leftover OpenClaw directories after migration")
|
||||
print()
|
||||
print("Run 'hermes claw <command> --help' for options.")
|
||||
print("Run 'hermes claw migrate --help' for migration options.")
|
||||
|
||||
|
||||
def _cmd_migrate(args):
|
||||
"""Run the OpenClaw → Hermes migration."""
|
||||
# Check current and legacy OpenClaw directories
|
||||
explicit_source = getattr(args, "source", None)
|
||||
if explicit_source:
|
||||
source_dir = Path(explicit_source)
|
||||
else:
|
||||
source_dir = Path.home() / ".openclaw"
|
||||
if not source_dir.is_dir():
|
||||
# Try legacy directory names
|
||||
for legacy in (".clawdbot", ".moldbot"):
|
||||
candidate = Path.home() / legacy
|
||||
if candidate.is_dir():
|
||||
source_dir = candidate
|
||||
break
|
||||
source_dir = Path(getattr(args, "source", None) or Path.home() / ".openclaw")
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
preset = getattr(args, "preset", "full")
|
||||
overwrite = getattr(args, "overwrite", False)
|
||||
@@ -296,168 +198,6 @@ def _cmd_migrate(args):
|
||||
# Print results
|
||||
_print_migration_report(report, dry_run)
|
||||
|
||||
# After successful non-dry-run migration, offer to archive the source directory
|
||||
if not dry_run and report.get("summary", {}).get("migrated", 0) > 0:
|
||||
_offer_source_archival(source_dir, getattr(args, "yes", False))
|
||||
|
||||
|
||||
def _offer_source_archival(source_dir: Path, auto_yes: bool = False):
|
||||
"""After migration, offer to rename the source directory to prevent state fragmentation.
|
||||
|
||||
OpenClaw workspace directories contain state files (todo.json, sessions, etc.)
|
||||
that the agent may discover and write to, causing confusion. Renaming the
|
||||
directory prevents this.
|
||||
"""
|
||||
if not source_dir.is_dir():
|
||||
return
|
||||
|
||||
# Scan for state files that could cause problems
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
|
||||
print()
|
||||
print_header("Post-Migration Cleanup")
|
||||
print_info("The OpenClaw directory still exists and contains workspace state files")
|
||||
print_info("that can confuse the agent (todo lists, sessions, logs).")
|
||||
if state_files:
|
||||
print()
|
||||
print(color(" Found state files:", Colors.YELLOW))
|
||||
# Show up to 10 most relevant findings
|
||||
for path, desc in state_files[:10]:
|
||||
print(f" {desc}")
|
||||
if len(state_files) > 10:
|
||||
print(f" ... and {len(state_files) - 10} more")
|
||||
print()
|
||||
print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/")
|
||||
print_info("This prevents the agent from discovering old workspace directories.")
|
||||
print_info("You can always rename it back if needed.")
|
||||
print()
|
||||
|
||||
if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True):
|
||||
try:
|
||||
archive_path = _archive_directory(source_dir)
|
||||
print_success(f"Archived: {source_dir} → {archive_path}")
|
||||
print_info("The original directory has been renamed, not deleted.")
|
||||
print_info(f"To undo: mv {archive_path} {source_dir}")
|
||||
except OSError as e:
|
||||
print_error(f"Could not archive: {e}")
|
||||
print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration")
|
||||
else:
|
||||
print_info("Skipped. You can archive later with: hermes claw cleanup")
|
||||
|
||||
|
||||
def _cmd_cleanup(args):
|
||||
"""Archive leftover OpenClaw directories after migration.
|
||||
|
||||
Scans for OpenClaw directories that still exist after migration and offers
|
||||
to rename them to .pre-migration to prevent state fragmentation.
|
||||
"""
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
auto_yes = getattr(args, "yes", False)
|
||||
explicit_source = getattr(args, "source", None)
|
||||
|
||||
print()
|
||||
print(
|
||||
color(
|
||||
"┌─────────────────────────────────────────────────────────┐",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
print(
|
||||
color(
|
||||
"│ ⚕ Hermes — OpenClaw Cleanup │",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
print(
|
||||
color(
|
||||
"└─────────────────────────────────────────────────────────┘",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
|
||||
# Find OpenClaw directories
|
||||
if explicit_source:
|
||||
dirs_to_check = [Path(explicit_source)]
|
||||
else:
|
||||
dirs_to_check = _find_openclaw_dirs()
|
||||
|
||||
if not dirs_to_check:
|
||||
print()
|
||||
print_success("No OpenClaw directories found. Nothing to clean up.")
|
||||
return
|
||||
|
||||
total_archived = 0
|
||||
|
||||
for source_dir in dirs_to_check:
|
||||
print()
|
||||
print_header(f"Found: {source_dir}")
|
||||
|
||||
# Scan for state files
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
|
||||
# Show directory stats
|
||||
try:
|
||||
workspace_dirs = [
|
||||
d for d in source_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith(".")
|
||||
and any((d / name).exists() for name in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md"))
|
||||
]
|
||||
except OSError:
|
||||
workspace_dirs = []
|
||||
|
||||
if workspace_dirs:
|
||||
print_info(f"Workspace directories: {len(workspace_dirs)}")
|
||||
for ws in workspace_dirs[:5]:
|
||||
items = []
|
||||
if (ws / "todo.json").exists():
|
||||
items.append("todo.json")
|
||||
if (ws / "sessions").is_dir():
|
||||
items.append("sessions/")
|
||||
if (ws / "SOUL.md").exists():
|
||||
items.append("SOUL.md")
|
||||
if (ws / "MEMORY.md").exists():
|
||||
items.append("MEMORY.md")
|
||||
detail = ", ".join(items) if items else "empty"
|
||||
print(f" {ws.name}/ ({detail})")
|
||||
if len(workspace_dirs) > 5:
|
||||
print(f" ... and {len(workspace_dirs) - 5} more")
|
||||
|
||||
if state_files:
|
||||
print()
|
||||
print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW))
|
||||
for path, desc in state_files[:8]:
|
||||
print(f" {desc}")
|
||||
if len(state_files) > 8:
|
||||
print(f" ... and {len(state_files) - 8} more")
|
||||
|
||||
print()
|
||||
|
||||
if dry_run:
|
||||
archive_path = _archive_directory(source_dir, dry_run=True)
|
||||
print_info(f"Would archive: {source_dir} → {archive_path}")
|
||||
else:
|
||||
if auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True):
|
||||
try:
|
||||
archive_path = _archive_directory(source_dir)
|
||||
print_success(f"Archived: {source_dir} → {archive_path}")
|
||||
total_archived += 1
|
||||
except OSError as e:
|
||||
print_error(f"Could not archive: {e}")
|
||||
print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration")
|
||||
else:
|
||||
print_info("Skipped.")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
if dry_run:
|
||||
print_info(f"Dry run complete. {len(dirs_to_check)} directory(ies) would be archived.")
|
||||
print_info("Run without --dry-run to archive them.")
|
||||
elif total_archived:
|
||||
print_success(f"Cleaned up {total_archived} OpenClaw directory(ies).")
|
||||
print_info("Directories were renamed, not deleted. You can undo by renaming them back.")
|
||||
else:
|
||||
print_info("No directories were archived.")
|
||||
|
||||
|
||||
def _print_migration_report(report: dict, dry_run: bool):
|
||||
"""Print a formatted migration report."""
|
||||
|
||||
@@ -12,8 +12,6 @@ import os
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CODEX_MODELS: List[str] = [
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
@@ -21,9 +19,8 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
]
|
||||
|
||||
|
||||
+2
-18
@@ -1,24 +1,8 @@
|
||||
"""Shared ANSI color utilities for Hermes CLI modules."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def should_use_color() -> bool:
|
||||
"""Return True when colored output is appropriate.
|
||||
|
||||
Respects the NO_COLOR environment variable (https://no-color.org/)
|
||||
and TERM=dumb, in addition to the existing TTY check.
|
||||
"""
|
||||
if os.environ.get("NO_COLOR") is not None:
|
||||
return False
|
||||
if os.environ.get("TERM") == "dumb":
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
@@ -32,7 +16,7 @@ class Colors:
|
||||
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
"""Apply color codes to text (only when color output is appropriate)."""
|
||||
if not should_use_color():
|
||||
"""Apply color codes to text (only when output is a TTY)."""
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
@@ -71,7 +71,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
aliases=("q",), args_hint="<prompt>"),
|
||||
CommandDef("status", "Show session info", "Session",
|
||||
gateway_only=True),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
gateway_only=True, aliases=("set-home",)),
|
||||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||||
@@ -91,8 +90,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||||
"Configuration"),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide]",
|
||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
@@ -121,8 +118,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
"Tools & Skills", cli_only=True),
|
||||
|
||||
# Info
|
||||
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
||||
gateway_only=True, args_hint="[page]"),
|
||||
CommandDef("help", "Show available commands", "Info"),
|
||||
CommandDef("usage", "Show token usage for the current session", "Info"),
|
||||
CommandDef("insights", "Show usage insights and analytics", "Info",
|
||||
@@ -366,69 +361,6 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
return result
|
||||
|
||||
|
||||
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
||||
"""Return Telegram menu commands capped to the Bot API limit.
|
||||
|
||||
Priority order (higher priority = never bumped by overflow):
|
||||
1. Core CommandDef commands (always included)
|
||||
2. Plugin slash commands (take precedence over skills)
|
||||
3. Built-in skill commands (fill remaining slots, alphabetical)
|
||||
|
||||
Skills are the only tier that gets trimmed when the cap is hit.
|
||||
User-installed hub skills are excluded — accessible via /skills.
|
||||
|
||||
Returns:
|
||||
(menu_commands, hidden_count) where hidden_count is the number of
|
||||
skill commands omitted due to the cap.
|
||||
"""
|
||||
all_commands = list(telegram_bot_commands())
|
||||
|
||||
# Plugin slash commands get priority over skills
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
plugin_cmds = getattr(pm, "_plugin_commands", {})
|
||||
for cmd_name in sorted(plugin_cmds):
|
||||
tg_name = cmd_name.replace("-", "_")
|
||||
desc = "Plugin command"
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
all_commands.append((tg_name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remaining slots go to built-in skill commands (not hub-installed).
|
||||
skill_entries: list[tuple[str, str]] = []
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
_skills_dir = str(SKILLS_DIR.resolve())
|
||||
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
||||
skill_cmds = get_skill_commands()
|
||||
for cmd_key in sorted(skill_cmds):
|
||||
info = skill_cmds[cmd_key]
|
||||
skill_path = info.get("skill_md_path", "")
|
||||
if not skill_path.startswith(_skills_dir):
|
||||
continue
|
||||
if skill_path.startswith(_hub_dir):
|
||||
continue
|
||||
name = cmd_key.lstrip("/").replace("-", "_")
|
||||
desc = info.get("description", "")
|
||||
# Keep descriptions short — setMyCommands has an undocumented
|
||||
# total payload limit. 40 chars fits 100 commands safely.
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
skill_entries.append((name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Skills fill remaining slots — they're the only tier that gets trimmed
|
||||
remaining_slots = max(0, max_commands - len(all_commands))
|
||||
hidden_count = max(0, len(skill_entries) - remaining_slots)
|
||||
all_commands.extend(skill_entries[:remaining_slots])
|
||||
return all_commands[:max_commands], hidden_count
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
|
||||
+12
-85
@@ -34,8 +34,6 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
@@ -52,86 +50,26 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
# Managed mode (NixOS declarative config)
|
||||
# =============================================================================
|
||||
|
||||
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
|
||||
_MANAGED_SYSTEM_NAMES = {
|
||||
"brew": "Homebrew",
|
||||
"homebrew": "Homebrew",
|
||||
"nix": "NixOS",
|
||||
"nixos": "NixOS",
|
||||
}
|
||||
|
||||
|
||||
def get_managed_system() -> Optional[str]:
|
||||
"""Return the package manager owning this install, if any."""
|
||||
raw = os.getenv("HERMES_MANAGED", "").strip()
|
||||
if raw:
|
||||
normalized = raw.lower()
|
||||
if normalized in _MANAGED_TRUE_VALUES:
|
||||
return "NixOS"
|
||||
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
|
||||
|
||||
managed_marker = get_hermes_home() / ".managed"
|
||||
if managed_marker.exists():
|
||||
return "NixOS"
|
||||
return None
|
||||
|
||||
|
||||
def is_managed() -> bool:
|
||||
"""Check if Hermes is running in package-manager-managed mode.
|
||||
"""Check if hermes is running in Nix-managed mode.
|
||||
|
||||
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
||||
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
||||
script, so interactive shells also see it).
|
||||
"""
|
||||
return get_managed_system() is not None
|
||||
|
||||
|
||||
def get_managed_update_command() -> Optional[str]:
|
||||
"""Return the preferred upgrade command for a managed install."""
|
||||
managed_system = get_managed_system()
|
||||
if managed_system == "Homebrew":
|
||||
return "brew upgrade hermes-agent"
|
||||
if managed_system == "NixOS":
|
||||
return "sudo nixos-rebuild switch"
|
||||
return None
|
||||
|
||||
|
||||
def recommended_update_command() -> str:
|
||||
"""Return the best update command for the current installation."""
|
||||
return get_managed_update_command() or "hermes update"
|
||||
|
||||
|
||||
def format_managed_message(action: str = "modify this Hermes installation") -> str:
|
||||
"""Build a user-facing error for managed installs."""
|
||||
managed_system = get_managed_system() or "a package manager"
|
||||
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
|
||||
|
||||
if managed_system == "NixOS":
|
||||
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
|
||||
return (
|
||||
f"Cannot {action}: this Hermes installation is managed by NixOS "
|
||||
f"(HERMES_MANAGED={env_hint}).\n"
|
||||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||||
" sudo nixos-rebuild switch"
|
||||
)
|
||||
|
||||
if managed_system == "Homebrew":
|
||||
env_hint = raw or "homebrew"
|
||||
return (
|
||||
f"Cannot {action}: this Hermes installation is managed by Homebrew "
|
||||
f"(HERMES_MANAGED={env_hint}).\n"
|
||||
"Use:\n"
|
||||
" brew upgrade hermes-agent"
|
||||
)
|
||||
|
||||
return (
|
||||
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
|
||||
"Use your package manager to upgrade or reinstall Hermes."
|
||||
)
|
||||
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
managed_marker = get_hermes_home() / ".managed"
|
||||
return managed_marker.exists()
|
||||
|
||||
def managed_error(action: str = "modify configuration"):
|
||||
"""Print user-friendly error for managed mode."""
|
||||
print(format_managed_message(action), file=sys.stderr)
|
||||
print(
|
||||
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
|
||||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||||
" sudo nixos-rebuild switch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -283,8 +221,7 @@ DEFAULT_CONFIG = {
|
||||
"model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o"
|
||||
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
|
||||
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
|
||||
"timeout": 30, # seconds — LLM API call timeout; increase for slow local vision models
|
||||
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
|
||||
"timeout": 30, # seconds — increase for slow local vision models
|
||||
},
|
||||
"web_extract": {
|
||||
"provider": "auto",
|
||||
@@ -348,7 +285,6 @@ DEFAULT_CONFIG = {
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
},
|
||||
|
||||
# Privacy settings
|
||||
@@ -468,7 +404,6 @@ DEFAULT_CONFIG = {
|
||||
# off — skip all approval prompts (equivalent to --yolo)
|
||||
"approvals": {
|
||||
"mode": "manual",
|
||||
"timeout": 60,
|
||||
},
|
||||
|
||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||
@@ -766,14 +701,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"CAMOFOX_URL": {
|
||||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||||
"prompt": "Camofox server URL",
|
||||
"url": "https://github.com/jo-inc/camofox-browser",
|
||||
"tools": ["browser_navigate", "browser_click"],
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"FAL_KEY": {
|
||||
"description": "FAL API key for image generation",
|
||||
"prompt": "FAL API key",
|
||||
|
||||
@@ -4,7 +4,6 @@ Used by `hermes tools` and `hermes skills` for interactive checklists.
|
||||
Provides a curses multi-select with keyboard navigation, plus a
|
||||
text-based numbered fallback for terminals without curses support.
|
||||
"""
|
||||
import sys
|
||||
from typing import Callable, List, Optional, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -32,11 +31,6 @@ def curses_checklist(
|
||||
if cancel_returns is None:
|
||||
cancel_returns = set(selected)
|
||||
|
||||
# Safety: curses and input() both hang or spin when stdin is not a
|
||||
# terminal (e.g. subprocess pipe). Return defaults immediately.
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_returns
|
||||
|
||||
try:
|
||||
import curses
|
||||
chosen = set(selected)
|
||||
|
||||
+8
-15
@@ -406,11 +406,8 @@ def run_doctor(args):
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
# Check if docker daemon is running
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = None
|
||||
if result is not None and result.returncode == 0:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
check_ok("docker", "(daemon running)")
|
||||
else:
|
||||
check_fail("docker daemon not running")
|
||||
@@ -429,16 +426,12 @@ def run_doctor(args):
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST")
|
||||
if ssh_host:
|
||||
# Try to connect
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = None
|
||||
if result is not None and result.returncode == 0:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
check_ok(f"SSH connection to {ssh_host}")
|
||||
else:
|
||||
check_fail(f"SSH connection to {ssh_host}")
|
||||
|
||||
@@ -1322,59 +1322,6 @@ _PLATFORMS = [
|
||||
"help": "The AppSecret from your DingTalk application credentials."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "feishu",
|
||||
"label": "Feishu / Lark",
|
||||
"emoji": "🪽",
|
||||
"token_var": "FEISHU_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)",
|
||||
"2. Create an app and copy the App ID and App Secret",
|
||||
"3. Enable the Bot capability for the app",
|
||||
"4. Choose WebSocket (recommended) or Webhook connection mode",
|
||||
"5. Add the bot to a group chat or message it directly",
|
||||
"6. Restrict access with FEISHU_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "FEISHU_APP_ID", "prompt": "App ID", "password": False,
|
||||
"help": "The App ID from your Feishu/Lark application."},
|
||||
{"name": "FEISHU_APP_SECRET", "prompt": "App Secret", "password": True,
|
||||
"help": "The App Secret from your Feishu/Lark application."},
|
||||
{"name": "FEISHU_DOMAIN", "prompt": "Domain — feishu or lark (default: feishu)", "password": False,
|
||||
"help": "Use 'feishu' for Feishu China, or 'lark' for Lark international."},
|
||||
{"name": "FEISHU_CONNECTION_MODE", "prompt": "Connection mode — websocket or webhook (default: websocket)", "password": False,
|
||||
"help": "websocket is recommended unless you specifically need webhook mode."},
|
||||
{"name": "FEISHU_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which Feishu/Lark users can interact with the bot."},
|
||||
{"name": "FEISHU_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom",
|
||||
"label": "WeCom (Enterprise WeChat)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_BOT_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create AI Bot",
|
||||
"2. Copy the Bot ID and Secret from the bot's credentials page",
|
||||
"3. The bot connects via WebSocket — no public endpoint needed",
|
||||
"4. Add the bot to a group chat or message it directly in WeCom",
|
||||
"5. Restrict access with WECOM_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False,
|
||||
"help": "The Bot ID from your WeCom AI Bot."},
|
||||
{"name": "WECOM_SECRET", "prompt": "Secret", "password": True,
|
||||
"help": "The secret from your WeCom AI Bot."},
|
||||
{"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the bot."},
|
||||
{"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+17
-73
@@ -50,23 +50,6 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def _require_tty(command_name: str) -> None:
|
||||
"""Exit with a clear error if stdin is not a terminal.
|
||||
|
||||
Interactive TUI commands (hermes tools, hermes setup, hermes model) use
|
||||
curses or input() prompts that spin at 100% CPU when stdin is a pipe.
|
||||
This guard prevents accidental non-interactive invocation.
|
||||
"""
|
||||
if not sys.stdin.isatty():
|
||||
print(
|
||||
f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
|
||||
f"It cannot be run through a pipe or non-interactive subprocess.\n"
|
||||
f"Run it directly in your terminal instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@@ -634,7 +617,6 @@ def cmd_gateway(args):
|
||||
|
||||
def cmd_whatsapp(args):
|
||||
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
|
||||
_require_tty("whatsapp")
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
@@ -821,14 +803,12 @@ def cmd_whatsapp(args):
|
||||
|
||||
def cmd_setup(args):
|
||||
"""Interactive setup wizard."""
|
||||
_require_tty("setup")
|
||||
from hermes_cli.setup import run_setup_wizard
|
||||
run_setup_wizard(args)
|
||||
|
||||
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
_require_tty("model")
|
||||
from hermes_cli.auth import (
|
||||
resolve_provider, AuthError, format_auth_error,
|
||||
)
|
||||
@@ -1104,20 +1084,14 @@ def _model_flow_nous(config, current_model=""):
|
||||
# login_nous already handles model selection + config update
|
||||
return
|
||||
|
||||
# Already logged in — use curated model list (same as OpenRouter defaults).
|
||||
# The live /models endpoint returns hundreds of models; the curated list
|
||||
# shows only agentic models users recognize from OpenRouter.
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
||||
if not model_ids:
|
||||
print("No curated models available for Nous Portal.")
|
||||
return
|
||||
|
||||
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
|
||||
|
||||
# Verify credentials are still valid (catches expired sessions early)
|
||||
# Already logged in — fetch models and select
|
||||
print("Fetching models from Nous Portal...")
|
||||
try:
|
||||
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
|
||||
model_ids = fetch_nous_models(
|
||||
inference_base_url=creds.get("base_url", ""),
|
||||
api_key=creds.get("api_key", ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
relogin = isinstance(exc, AuthError) and exc.relogin_required
|
||||
msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
|
||||
@@ -1134,7 +1108,11 @@ def _model_flow_nous(config, current_model=""):
|
||||
except Exception as login_exc:
|
||||
print(f"Re-login failed: {login_exc}")
|
||||
return
|
||||
print(f"Could not verify credentials: {msg}")
|
||||
print(f"Could not fetch models: {msg}")
|
||||
return
|
||||
|
||||
if not model_ids:
|
||||
print("No models returned by the inference API.")
|
||||
return
|
||||
|
||||
selected = _prompt_model_selection(model_ids, current_model=current_model)
|
||||
@@ -1291,7 +1269,7 @@ def _model_flow_custom(config):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = effective_url
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2073,7 +2051,7 @@ def _model_flow_kimi(config, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2147,7 +2125,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
live_models = fetch_api_models(api_key_for_probe, effective_base)
|
||||
|
||||
if live_models and len(live_models) >= len(curated):
|
||||
if live_models:
|
||||
model_list = live_models
|
||||
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
|
||||
else:
|
||||
@@ -2180,7 +2158,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2467,14 +2445,10 @@ def cmd_version(args):
|
||||
# Show update status (synchronous — acceptable since user asked for version info)
|
||||
try:
|
||||
from hermes_cli.banner import check_for_updates
|
||||
from hermes_cli.config import recommended_update_command
|
||||
behind = check_for_updates()
|
||||
if behind and behind > 0:
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
print(
|
||||
f"Update available: {behind} {commits_word} behind — "
|
||||
f"run '{recommended_update_command()}'"
|
||||
)
|
||||
print(f"Update available: {behind} {commits_word} behind — run 'hermes update'")
|
||||
elif behind == 0:
|
||||
print("Up to date")
|
||||
except Exception:
|
||||
@@ -2483,7 +2457,6 @@ def cmd_version(args):
|
||||
|
||||
def cmd_uninstall(args):
|
||||
"""Uninstall Hermes Agent."""
|
||||
_require_tty("uninstall")
|
||||
from hermes_cli.uninstall import run_uninstall
|
||||
run_uninstall(args)
|
||||
|
||||
@@ -2825,11 +2798,6 @@ def _invalidate_update_cache():
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version."""
|
||||
import shutil
|
||||
from hermes_cli.config import is_managed, managed_error
|
||||
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
@@ -3038,7 +3006,7 @@ def cmd_update(args):
|
||||
import shutil
|
||||
if shutil.which("npm"):
|
||||
print("→ Updating Node.js dependencies...")
|
||||
subprocess.run(["npm", "ci", "--silent"], cwd=PROJECT_ROOT, check=False)
|
||||
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
|
||||
|
||||
print()
|
||||
print("✓ Code updated!")
|
||||
@@ -4161,7 +4129,6 @@ For more help on a command:
|
||||
def cmd_skills(args):
|
||||
# Route 'config' action to skills_config module
|
||||
if getattr(args, 'skills_action', None) == 'config':
|
||||
_require_tty("skills config")
|
||||
from hermes_cli.skills_config import skills_command as skills_config_command
|
||||
skills_config_command(args)
|
||||
else:
|
||||
@@ -4372,7 +4339,6 @@ For more help on a command:
|
||||
from hermes_cli.tools_config import tools_disable_enable_command
|
||||
tools_disable_enable_command(args)
|
||||
else:
|
||||
_require_tty("tools")
|
||||
from hermes_cli.tools_config import tools_command
|
||||
tools_command(args)
|
||||
|
||||
@@ -4712,28 +4678,6 @@ For more help on a command:
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
|
||||
# claw cleanup
|
||||
claw_cleanup = claw_subparsers.add_parser(
|
||||
"cleanup",
|
||||
aliases=["clean"],
|
||||
help="Archive leftover OpenClaw directories after migration",
|
||||
description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation"
|
||||
)
|
||||
claw_cleanup.add_argument(
|
||||
"--source",
|
||||
help="Path to a specific OpenClaw directory to clean up"
|
||||
)
|
||||
claw_cleanup.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview what would be archived without making changes"
|
||||
)
|
||||
claw_cleanup.add_argument(
|
||||
"--yes", "-y",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
|
||||
def cmd_claw(args):
|
||||
from hermes_cli.claw import claw_command
|
||||
claw_command(args)
|
||||
|
||||
@@ -511,10 +511,6 @@ def _interpolate_value(value: str) -> str:
|
||||
|
||||
def cmd_mcp_configure(args):
|
||||
"""Reconfigure which tools are enabled for an existing MCP server."""
|
||||
import sys as _sys
|
||||
if not _sys.stdin.isatty():
|
||||
print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr)
|
||||
_sys.exit(1)
|
||||
name = args.name
|
||||
servers = _get_mcp_servers()
|
||||
|
||||
|
||||
@@ -152,34 +152,6 @@ class PluginContext:
|
||||
self._manager._plugin_tool_names.add(name)
|
||||
logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
|
||||
|
||||
# -- message injection --------------------------------------------------
|
||||
|
||||
def inject_message(self, content: str, role: str = "user") -> bool:
|
||||
"""Inject a message into the active conversation.
|
||||
|
||||
If the agent is idle (waiting for user input), this starts a new turn.
|
||||
If the agent is running, this interrupts and injects the message.
|
||||
|
||||
This enables plugins (e.g. remote control viewers, messaging bridges)
|
||||
to send messages into the conversation from external sources.
|
||||
|
||||
Returns True if the message was queued successfully.
|
||||
"""
|
||||
cli = self._manager._cli_ref
|
||||
if cli is None:
|
||||
logger.warning("inject_message: no CLI reference (not available in gateway mode)")
|
||||
return False
|
||||
|
||||
msg = content if role == "user" else f"[{role}] {content}"
|
||||
|
||||
if getattr(cli, "_agent_running", False):
|
||||
# Agent is mid-turn — interrupt with the message
|
||||
cli._interrupt_queue.put(msg)
|
||||
else:
|
||||
# Agent is idle — queue as next input
|
||||
cli._pending_input.put(msg)
|
||||
return True
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
@@ -212,7 +184,6 @@ class PluginManager:
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._discovered: bool = False
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public
|
||||
|
||||
@@ -265,11 +265,10 @@ def cmd_install(identifier: str, force: bool = False) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
||||
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
||||
f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
|
||||
f"Run [bold]hermes update[/bold] to get a newer installer."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
+10
-40
@@ -18,8 +18,6 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
@@ -603,15 +601,13 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||
).exists()
|
||||
)
|
||||
if get_env_value("CAMOFOX_URL"):
|
||||
tool_status.append(("Browser Automation (Camofox)", True, None))
|
||||
elif get_env_value("BROWSERBASE_API_KEY"):
|
||||
if get_env_value("BROWSERBASE_API_KEY"):
|
||||
tool_status.append(("Browser Automation (Browserbase)", True, None))
|
||||
elif _ab_found:
|
||||
tool_status.append(("Browser Automation (local)", True, None))
|
||||
else:
|
||||
tool_status.append(
|
||||
("Browser Automation", False, "npm install -g agent-browser or set CAMOFOX_URL")
|
||||
("Browser Automation", False, "npm install -g agent-browser")
|
||||
)
|
||||
|
||||
# FAL (image generation)
|
||||
@@ -1006,9 +1002,10 @@ def setup_model_provider(config: dict):
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
timeout_seconds=15.0,
|
||||
)
|
||||
# Use curated model list instead of full /models dump
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
nous_models = _PROVIDER_MODELS.get("nous", [])
|
||||
nous_models = fetch_nous_models(
|
||||
inference_base_url=creds.get("base_url", ""),
|
||||
api_key=creds.get("api_key", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch Nous models after login: %s", e)
|
||||
|
||||
@@ -2713,38 +2710,10 @@ def setup_gateway(config: dict):
|
||||
if token or get_env_value("MATRIX_PASSWORD"):
|
||||
# E2EE
|
||||
print()
|
||||
want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
|
||||
if want_e2ee:
|
||||
if prompt_yes_no("Enable end-to-end encryption (E2EE)?", False):
|
||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||
print_success("E2EE enabled")
|
||||
|
||||
# Auto-install matrix-nio
|
||||
matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio"
|
||||
try:
|
||||
__import__("nio")
|
||||
except ImportError:
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(f"Install failed — run manually: pip install '{matrix_pkg}'")
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
print_info(" Requires: pip install 'matrix-nio[e2e]'")
|
||||
|
||||
# Allowed users
|
||||
print()
|
||||
@@ -3123,7 +3092,8 @@ def _skip_configured_section(
|
||||
|
||||
|
||||
_OPENCLAW_SCRIPT = (
|
||||
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||
PROJECT_ROOT
|
||||
/ "optional-skills"
|
||||
/ "migration"
|
||||
/ "openclaw-migration"
|
||||
/ "scripts"
|
||||
|
||||
@@ -28,8 +28,6 @@ PLATFORMS = {
|
||||
"mattermost": "💬 Mattermost",
|
||||
"matrix": "💬 Matrix",
|
||||
"dingtalk": "💬 DingTalk",
|
||||
"feishu": "🪽 Feishu",
|
||||
"wecom": "💬 WeCom",
|
||||
}
|
||||
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -354,14 +354,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
extra_metadata.update(getattr(bundle, "metadata", {}) or {})
|
||||
|
||||
# Quarantine the bundle
|
||||
try:
|
||||
q_path = quarantine_bundle(bundle)
|
||||
except ValueError as exc:
|
||||
c.print(f"[bold red]Installation blocked:[/] {exc}\n")
|
||||
from tools.skills_hub import append_audit_log
|
||||
append_audit_log("BLOCKED", bundle.name, bundle.source,
|
||||
bundle.trust_level, "invalid_path", str(exc))
|
||||
return
|
||||
q_path = quarantine_bundle(bundle)
|
||||
c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]")
|
||||
|
||||
# Scan
|
||||
@@ -421,15 +414,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
return
|
||||
|
||||
# Install
|
||||
try:
|
||||
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
||||
except ValueError as exc:
|
||||
c.print(f"[bold red]Installation blocked:[/] {exc}\n")
|
||||
shutil.rmtree(q_path, ignore_errors=True)
|
||||
from tools.skills_hub import append_audit_log
|
||||
append_audit_log("BLOCKED", bundle.name, bundle.source,
|
||||
bundle.trust_level, "invalid_path", str(exc))
|
||||
return
|
||||
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
||||
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
||||
|
||||
+12
-23
@@ -254,9 +254,6 @@ def show_status(args):
|
||||
"Slack": ("SLACK_BOT_TOKEN", None),
|
||||
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
|
||||
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
|
||||
"DingTalk": ("DINGTALK_CLIENT_ID", None),
|
||||
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
|
||||
"WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
@@ -285,31 +282,23 @@ def show_status(args):
|
||||
_gw_svc = get_service_name()
|
||||
except Exception:
|
||||
_gw_svc = "hermes-gateway"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", _gw_svc],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
except subprocess.TimeoutExpired:
|
||||
is_active = False
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", _gw_svc],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
from hermes_cli.gateway import get_launchd_label
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
is_loaded = False
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
|
||||
print(" Manager: launchd")
|
||||
else:
|
||||
|
||||
@@ -140,9 +140,7 @@ PLATFORMS = {
|
||||
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
|
||||
"email": {"label": "📧 Email", "default_toolset": "hermes-email"},
|
||||
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"feishu": {"label": "🪽 Feishu", "default_toolset": "hermes-feishu"},
|
||||
"wecom": {"label": "💬 WeCom", "default_toolset": "hermes-wecom"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
|
||||
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
|
||||
}
|
||||
@@ -273,16 +271,6 @@ TOOL_CATEGORIES = {
|
||||
"browser_provider": "browser-use",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"tag": "Local anti-detection browser (Firefox/Camoufox)",
|
||||
"env_vars": [
|
||||
{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377",
|
||||
"url": "https://github.com/jo-inc/camofox-browser"},
|
||||
],
|
||||
"browser_provider": "camofox",
|
||||
"post_setup": "camofox",
|
||||
},
|
||||
],
|
||||
},
|
||||
"homeassistant": {
|
||||
@@ -347,28 +335,6 @@ def _run_post_setup(post_setup_key: str):
|
||||
elif not node_modules.exists():
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
|
||||
elif post_setup_key == "camofox":
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser"
|
||||
if not camofox_dir.exists() and shutil.which("npm"):
|
||||
_print_info(" Installing Camofox browser server...")
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["npm", "install", "--silent"],
|
||||
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" Camofox installed")
|
||||
else:
|
||||
_print_warning(" npm install failed - run manually: npm install")
|
||||
if camofox_dir.exists():
|
||||
_print_info(" Start the Camofox server:")
|
||||
_print_info(" npx @askjo/camoufox-browser")
|
||||
_print_info(" First run downloads the Camoufox engine (~300MB)")
|
||||
_print_info(" Or use Docker: docker run -p 9377:9377 jo-inc/camofox-browser")
|
||||
elif not shutil.which("npm"):
|
||||
_print_warning(" Node.js not found. Install Camofox via Docker:")
|
||||
_print_info(" docker run -p 9377:9377 jo-inc/camofox-browser")
|
||||
|
||||
elif post_setup_key == "rl_training":
|
||||
try:
|
||||
__import__("tinker_atropos")
|
||||
@@ -597,9 +563,7 @@ def _toolset_has_keys(ts_key: str) -> bool:
|
||||
if cat:
|
||||
for provider in cat.get("providers", []):
|
||||
env_vars = provider.get("env_vars", [])
|
||||
if not env_vars:
|
||||
return True # No-key provider (e.g. Local Browser, Edge TTS)
|
||||
if all(get_env_value(e["key"]) for e in env_vars):
|
||||
if env_vars and all(get_env_value(e["key"]) for e in env_vars):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -17,20 +17,6 @@ def get_hermes_home() -> Path:
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def get_optional_skills_dir(default: Path | None = None) -> Path:
|
||||
"""Return the optional-skills directory, honoring package-manager wrappers.
|
||||
|
||||
Packaged installs may ship ``optional-skills`` outside the Python package
|
||||
tree and expose it via ``HERMES_OPTIONAL_SKILLS``.
|
||||
"""
|
||||
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
if default is not None:
|
||||
return default
|
||||
return get_hermes_home() / "optional-skills"
|
||||
|
||||
|
||||
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
||||
"""Resolve a Hermes subdirectory with backward compatibility.
|
||||
|
||||
|
||||
@@ -10,27 +10,16 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
|
||||
|
||||
HOST = "hermes"
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
"""Return the active Honcho config path for reading (instance-local or global)."""
|
||||
"""Return the active Honcho config path (instance-local or global)."""
|
||||
return resolve_config_path()
|
||||
|
||||
|
||||
def _local_config_path() -> Path:
|
||||
"""Return the instance-local Honcho config path for writing.
|
||||
|
||||
Always returns $HERMES_HOME/honcho.json so each profile/instance gets
|
||||
its own config file. The global ~/.honcho/config.json is only used as
|
||||
a read fallback (via resolve_config_path) for cross-app interop.
|
||||
"""
|
||||
return get_hermes_home() / "honcho.json"
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
path = _config_path()
|
||||
if path.exists():
|
||||
@@ -42,7 +31,7 @@ def _read_config() -> dict:
|
||||
|
||||
|
||||
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||
path = path or _local_config_path()
|
||||
path = path or _config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
||||
@@ -106,13 +95,13 @@ def cmd_setup(args) -> None:
|
||||
"""Interactive Honcho setup wizard."""
|
||||
cfg = _read_config()
|
||||
|
||||
write_path = _local_config_path()
|
||||
read_path = _config_path()
|
||||
active_path = _config_path()
|
||||
print("\nHoncho memory setup\n" + "─" * 40)
|
||||
print(" Honcho gives Hermes persistent cross-session memory.")
|
||||
print(f" Config: {write_path}")
|
||||
if read_path != write_path and read_path.exists():
|
||||
print(f" (seeding from existing config at {read_path})")
|
||||
if active_path != GLOBAL_CONFIG_PATH:
|
||||
print(f" Instance config: {active_path}")
|
||||
else:
|
||||
print(" Config is shared with other hosts at ~/.honcho/config.json")
|
||||
print()
|
||||
|
||||
if not _ensure_sdk_installed():
|
||||
@@ -200,7 +189,7 @@ def cmd_setup(args) -> None:
|
||||
hermes_host.setdefault("saveMessages", True)
|
||||
|
||||
_write_config(cfg)
|
||||
print(f"\n Config written to {write_path}")
|
||||
print(f"\n Config written to {active_path}")
|
||||
|
||||
# Test connection
|
||||
print(" Testing connection... ", end="", flush=True)
|
||||
@@ -248,7 +237,6 @@ def cmd_status(args) -> None:
|
||||
cfg = _read_config()
|
||||
|
||||
active_path = _config_path()
|
||||
write_path = _local_config_path()
|
||||
|
||||
if not cfg:
|
||||
print(f" No Honcho config found at {active_path}")
|
||||
@@ -271,8 +259,6 @@ def cmd_status(args) -> None:
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
print(f" Host: {hcfg.host}")
|
||||
print(f" Config path: {active_path}")
|
||||
if write_path != active_path:
|
||||
print(f" Write path: {write_path} (instance-local)")
|
||||
print(f" AI peer: {hcfg.ai_peer}")
|
||||
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
||||
print(f" Session key: {hcfg.resolve_session_name()}")
|
||||
|
||||
@@ -304,29 +304,6 @@ def ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Optional[str]:
|
||||
"""Resolve an OpenClaw SecretInput value to a plain string.
|
||||
|
||||
SecretInput can be:
|
||||
- A plain string: "sk-..."
|
||||
- An env template: "${OPENROUTER_API_KEY}"
|
||||
- A SecretRef object: {"source": "env", "id": "OPENROUTER_API_KEY"}
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
# Check for env template: "${VAR_NAME}"
|
||||
m = re.match(r"^\$\{(\w+)\}$", value.strip())
|
||||
if m and env:
|
||||
return env.get(m.group(1), "").strip() or None
|
||||
return value.strip() or None
|
||||
if isinstance(value, dict):
|
||||
source = value.get("source", "")
|
||||
ref_id = value.get("id", "")
|
||||
if source == "env" and ref_id and env:
|
||||
return env.get(ref_id, "").strip() or None
|
||||
# File/exec sources can't be resolved here — return None
|
||||
return None
|
||||
|
||||
|
||||
def load_yaml_file(path: Path) -> Dict[str, Any]:
|
||||
if yaml is None or not path.exists():
|
||||
return {}
|
||||
@@ -913,20 +890,14 @@ class Migrator:
|
||||
self.record("command-allowlist", source, destination, "migrated", "Would merge patterns", added_patterns=added)
|
||||
|
||||
def load_openclaw_config(self) -> Dict[str, Any]:
|
||||
# Check current name and legacy config filenames
|
||||
for name in ("openclaw.json", "clawdbot.json", "moldbot.json"):
|
||||
config_path = self.source_root / name
|
||||
if config_path.exists():
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return {}
|
||||
|
||||
def load_openclaw_env(self) -> Dict[str, str]:
|
||||
"""Load the OpenClaw .env file for secrets that live there instead of config."""
|
||||
return parse_env_file(self.source_root / ".env")
|
||||
config_path = self.source_root / "openclaw.json"
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None:
|
||||
destination = self.target_root / ".env"
|
||||
@@ -1053,10 +1024,6 @@ class Migrator:
|
||||
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
|
||||
)
|
||||
|
||||
def _resolve_channel_secret(self, value: Any) -> Optional[str]:
|
||||
"""Resolve a channel config value that may be a SecretRef."""
|
||||
return resolve_secret_input(value, self.load_openclaw_env())
|
||||
|
||||
def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
additions: Dict[str, str] = {}
|
||||
@@ -1151,17 +1118,15 @@ class Migrator:
|
||||
secret_additions: Dict[str, str] = {}
|
||||
|
||||
# Extract provider API keys from models.providers
|
||||
# Note: apiKey values can be strings, env templates, or SecretRef objects
|
||||
openclaw_env = self.load_openclaw_env()
|
||||
providers = config.get("models", {}).get("providers", {})
|
||||
if isinstance(providers, dict):
|
||||
for provider_name, provider_cfg in providers.items():
|
||||
if not isinstance(provider_cfg, dict):
|
||||
continue
|
||||
raw_key = provider_cfg.get("apiKey")
|
||||
api_key = resolve_secret_input(raw_key, openclaw_env)
|
||||
if not api_key:
|
||||
api_key = provider_cfg.get("apiKey")
|
||||
if not isinstance(api_key, str) or not api_key.strip():
|
||||
continue
|
||||
api_key = api_key.strip()
|
||||
|
||||
base_url = provider_cfg.get("baseUrl", "")
|
||||
api_type = provider_cfg.get("api", "")
|
||||
@@ -1205,50 +1170,6 @@ class Migrator:
|
||||
if isinstance(oai_key, str) and oai_key.strip():
|
||||
secret_additions["VOICE_TOOLS_OPENAI_KEY"] = oai_key.strip()
|
||||
|
||||
# Also check the OpenClaw .env file — many users store keys there
|
||||
# instead of inline in openclaw.json
|
||||
openclaw_env = self.load_openclaw_env()
|
||||
env_key_mapping = {
|
||||
"OPENROUTER_API_KEY": "OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY": "OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY",
|
||||
"ELEVENLABS_API_KEY": "ELEVENLABS_API_KEY",
|
||||
"TELEGRAM_BOT_TOKEN": "TELEGRAM_BOT_TOKEN",
|
||||
"DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY",
|
||||
"GEMINI_API_KEY": "GEMINI_API_KEY",
|
||||
"ZAI_API_KEY": "ZAI_API_KEY",
|
||||
"MINIMAX_API_KEY": "MINIMAX_API_KEY",
|
||||
}
|
||||
for oc_key, hermes_key in env_key_mapping.items():
|
||||
val = openclaw_env.get(oc_key, "").strip()
|
||||
if val and hermes_key not in secret_additions:
|
||||
secret_additions[hermes_key] = val
|
||||
|
||||
# Check per-agent auth-profiles.json for additional credentials
|
||||
auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json"
|
||||
if auth_profiles_path.exists():
|
||||
try:
|
||||
profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8"))
|
||||
if isinstance(profiles, dict):
|
||||
# auth-profiles.json wraps profiles in a "profiles" key
|
||||
profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles
|
||||
for profile_name, profile_data in profile_entries.items():
|
||||
if not isinstance(profile_data, dict):
|
||||
continue
|
||||
# Canonical field is "key", "apiKey" is accepted as alias
|
||||
api_key = profile_data.get("key", "") or profile_data.get("apiKey", "")
|
||||
if not isinstance(api_key, str) or not api_key.strip():
|
||||
continue
|
||||
name_lower = profile_name.lower()
|
||||
if "openrouter" in name_lower and "OPENROUTER_API_KEY" not in secret_additions:
|
||||
secret_additions["OPENROUTER_API_KEY"] = api_key.strip()
|
||||
elif "openai" in name_lower and "OPENAI_API_KEY" not in secret_additions:
|
||||
secret_additions["OPENAI_API_KEY"] = api_key.strip()
|
||||
elif "anthropic" in name_lower and "ANTHROPIC_API_KEY" not in secret_additions:
|
||||
secret_additions["ANTHROPIC_API_KEY"] = api_key.strip()
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
if secret_additions:
|
||||
self.merge_env_values(secret_additions, "provider-keys", self.source_root / "openclaw.json")
|
||||
else:
|
||||
@@ -1297,11 +1218,7 @@ class Migrator:
|
||||
|
||||
if self.execute:
|
||||
backup_path = self.maybe_backup(destination)
|
||||
existing_model = hermes_config.get("model")
|
||||
if isinstance(existing_model, dict):
|
||||
existing_model["default"] = model_str
|
||||
else:
|
||||
hermes_config["model"] = {"default": model_str}
|
||||
hermes_config["model"] = model_str
|
||||
dump_yaml_file(destination, hermes_config)
|
||||
self.record("model-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", model=model_str)
|
||||
else:
|
||||
@@ -1327,44 +1244,22 @@ class Migrator:
|
||||
if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge"):
|
||||
tts_data["provider"] = provider
|
||||
|
||||
# TTS provider settings live under messages.tts.providers.{provider}
|
||||
# in OpenClaw (not messages.tts.elevenlabs directly)
|
||||
providers = tts.get("providers") or {}
|
||||
|
||||
# Also check the top-level "talk" config which has provider settings too
|
||||
talk_cfg = (config or self.load_openclaw_config()).get("talk") or {}
|
||||
talk_providers = talk_cfg.get("providers") or {}
|
||||
|
||||
# Merge: messages.tts.providers takes priority, then talk.providers,
|
||||
# then legacy flat keys (messages.tts.elevenlabs, etc.)
|
||||
elevenlabs = (
|
||||
(providers.get("elevenlabs") or {})
|
||||
if isinstance(providers.get("elevenlabs"), dict) else
|
||||
(talk_providers.get("elevenlabs") or {})
|
||||
if isinstance(talk_providers.get("elevenlabs"), dict) else
|
||||
(tts.get("elevenlabs") or {})
|
||||
)
|
||||
elevenlabs = tts.get("elevenlabs", {})
|
||||
if isinstance(elevenlabs, dict):
|
||||
el_settings: Dict[str, str] = {}
|
||||
voice_id = elevenlabs.get("voiceId") or talk_cfg.get("voiceId")
|
||||
voice_id = elevenlabs.get("voiceId")
|
||||
if isinstance(voice_id, str) and voice_id.strip():
|
||||
el_settings["voice_id"] = voice_id.strip()
|
||||
model_id = elevenlabs.get("modelId") or talk_cfg.get("modelId")
|
||||
model_id = elevenlabs.get("modelId")
|
||||
if isinstance(model_id, str) and model_id.strip():
|
||||
el_settings["model_id"] = model_id.strip()
|
||||
if el_settings:
|
||||
tts_data["elevenlabs"] = el_settings
|
||||
|
||||
openai_tts = (
|
||||
(providers.get("openai") or {})
|
||||
if isinstance(providers.get("openai"), dict) else
|
||||
(talk_providers.get("openai") or {})
|
||||
if isinstance(talk_providers.get("openai"), dict) else
|
||||
(tts.get("openai") or {})
|
||||
)
|
||||
openai_tts = tts.get("openai", {})
|
||||
if isinstance(openai_tts, dict):
|
||||
oai_settings: Dict[str, str] = {}
|
||||
oai_model = openai_tts.get("model") or openai_tts.get("modelId")
|
||||
oai_model = openai_tts.get("model")
|
||||
if isinstance(oai_model, str) and oai_model.strip():
|
||||
oai_settings["model"] = oai_model.strip()
|
||||
oai_voice = openai_tts.get("voice")
|
||||
@@ -1373,11 +1268,7 @@ class Migrator:
|
||||
if oai_settings:
|
||||
tts_data["openai"] = oai_settings
|
||||
|
||||
edge_tts = (
|
||||
(providers.get("edge") or {})
|
||||
if isinstance(providers.get("edge"), dict) else
|
||||
(tts.get("edge") or {})
|
||||
)
|
||||
edge_tts = tts.get("edge", {})
|
||||
if isinstance(edge_tts, dict):
|
||||
edge_voice = edge_tts.get("voice")
|
||||
if isinstance(edge_voice, str) and edge_voice.strip():
|
||||
@@ -1407,29 +1298,15 @@ class Migrator:
|
||||
self.record("tts-config", source_path, destination, "migrated", "Would set TTS config", settings=list(tts_data.keys()))
|
||||
|
||||
def migrate_shared_skills(self) -> None:
|
||||
# Check all OpenClaw skill sources: managed, personal, project-level
|
||||
skill_sources = [
|
||||
(self.source_root / "skills", "shared-skills", "managed skills"),
|
||||
(Path.home() / ".agents" / "skills", "personal-skills", "personal cross-project skills"),
|
||||
(self.source_root / "workspace" / ".agents" / "skills", "project-skills", "project-level shared skills"),
|
||||
(self.source_root / "workspace.default" / ".agents" / "skills", "project-skills", "project-level shared skills"),
|
||||
]
|
||||
found_any = False
|
||||
for source_root, kind_label, desc in skill_sources:
|
||||
if source_root.exists():
|
||||
found_any = True
|
||||
self._import_skill_directory(source_root, kind_label, desc)
|
||||
if not found_any:
|
||||
destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME
|
||||
self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directories found")
|
||||
|
||||
def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str) -> None:
|
||||
"""Import skills from a single source directory into openclaw-imports."""
|
||||
source_root = self.source_root / "skills"
|
||||
destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME
|
||||
if not source_root.exists():
|
||||
self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directory found")
|
||||
return
|
||||
|
||||
skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()]
|
||||
if not skill_dirs:
|
||||
self.record(kind_label, source_root, destination_root, "skipped", f"No skills with SKILL.md found in {desc}")
|
||||
self.record("shared-skills", source_root, destination_root, "skipped", "No shared skills with SKILL.md found")
|
||||
return
|
||||
|
||||
for skill_dir in skill_dirs:
|
||||
@@ -1437,7 +1314,7 @@ class Migrator:
|
||||
final_destination = destination
|
||||
if destination.exists():
|
||||
if self.skill_conflict_mode == "skip":
|
||||
self.record(kind_label, skill_dir, destination, "conflict", "Destination skill already exists")
|
||||
self.record("shared-skill", skill_dir, destination, "conflict", "Destination skill already exists")
|
||||
continue
|
||||
if self.skill_conflict_mode == "rename":
|
||||
final_destination = self.resolve_skill_destination(destination)
|
||||
@@ -1452,19 +1329,19 @@ class Migrator:
|
||||
details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""}
|
||||
if final_destination != destination:
|
||||
details["renamed_from"] = str(destination)
|
||||
self.record(kind_label, skill_dir, final_destination, "migrated", **details)
|
||||
self.record("shared-skill", skill_dir, final_destination, "migrated", **details)
|
||||
else:
|
||||
if final_destination != destination:
|
||||
self.record(
|
||||
kind_label,
|
||||
"shared-skill",
|
||||
skill_dir,
|
||||
final_destination,
|
||||
"migrated",
|
||||
f"Would copy {desc} directory under a renamed folder",
|
||||
"Would copy shared skill directory under a renamed folder",
|
||||
renamed_from=str(destination),
|
||||
)
|
||||
else:
|
||||
self.record(kind_label, skill_dir, final_destination, "migrated", f"Would copy {desc} directory")
|
||||
self.record("shared-skill", skill_dir, final_destination, "migrated", "Would copy shared skill directory")
|
||||
|
||||
desc_path = destination_root / "DESCRIPTION.md"
|
||||
if self.execute:
|
||||
@@ -1641,7 +1518,6 @@ class Migrator:
|
||||
self.source_candidate("workspace/IDENTITY.md", "workspace.default/IDENTITY.md"),
|
||||
self.source_candidate("workspace/TOOLS.md", "workspace.default/TOOLS.md"),
|
||||
self.source_candidate("workspace/HEARTBEAT.md", "workspace.default/HEARTBEAT.md"),
|
||||
self.source_candidate("workspace/BOOTSTRAP.md", "workspace.default/BOOTSTRAP.md"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate:
|
||||
@@ -1913,9 +1789,8 @@ class Migrator:
|
||||
human_delay = defaults.get("humanDelay") or {}
|
||||
if human_delay:
|
||||
hd = hermes_cfg.get("human_delay") or {}
|
||||
hd_mode = human_delay.get("mode") or ("natural" if human_delay.get("enabled") else None)
|
||||
if hd_mode and hd_mode != "off":
|
||||
hd["mode"] = hd_mode
|
||||
if human_delay.get("enabled"):
|
||||
hd["mode"] = "natural"
|
||||
if human_delay.get("minMs"):
|
||||
hd["min_ms"] = human_delay["minMs"]
|
||||
if human_delay.get("maxMs"):
|
||||
@@ -1929,11 +1804,11 @@ class Migrator:
|
||||
changes = True
|
||||
|
||||
# Map terminal/exec settings
|
||||
exec_cfg = (config.get("tools") or {}).get("exec") or {}
|
||||
exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {}
|
||||
if exec_cfg:
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
if exec_cfg.get("timeoutSec") or exec_cfg.get("timeout"):
|
||||
terminal_cfg["timeout"] = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")
|
||||
if exec_cfg.get("timeout"):
|
||||
terminal_cfg["timeout"] = exec_cfg["timeout"]
|
||||
changes = True
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
|
||||
@@ -2008,34 +1883,24 @@ class Migrator:
|
||||
sr = hermes_cfg.get("session_reset") or {}
|
||||
changes = False
|
||||
|
||||
# OpenClaw uses session.reset (structured) and session.resetTriggers (string array)
|
||||
reset = session.get("reset") or {}
|
||||
reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or []
|
||||
reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {}
|
||||
if reset_triggers:
|
||||
daily = reset_triggers.get("daily") or {}
|
||||
idle = reset_triggers.get("idle") or {}
|
||||
|
||||
if reset:
|
||||
# Structured reset config: has mode, atHour, idleMinutes
|
||||
mode = reset.get("mode", "")
|
||||
if mode == "daily":
|
||||
if daily.get("enabled") and idle.get("enabled"):
|
||||
sr["mode"] = "both"
|
||||
elif daily.get("enabled"):
|
||||
sr["mode"] = "daily"
|
||||
elif mode == "idle":
|
||||
elif idle.get("enabled"):
|
||||
sr["mode"] = "idle"
|
||||
else:
|
||||
sr["mode"] = mode or "none"
|
||||
if reset.get("atHour") is not None:
|
||||
sr["at_hour"] = reset["atHour"]
|
||||
if reset.get("idleMinutes"):
|
||||
sr["idle_minutes"] = reset["idleMinutes"]
|
||||
changes = True
|
||||
elif isinstance(reset_triggers, list) and reset_triggers:
|
||||
# Simple string triggers: ["daily", "idle"]
|
||||
has_daily = "daily" in reset_triggers
|
||||
has_idle = "idle" in reset_triggers
|
||||
if has_daily and has_idle:
|
||||
sr["mode"] = "both"
|
||||
elif has_daily:
|
||||
sr["mode"] = "daily"
|
||||
elif has_idle:
|
||||
sr["mode"] = "idle"
|
||||
sr["mode"] = "none"
|
||||
|
||||
if daily.get("hour") is not None:
|
||||
sr["at_hour"] = daily["hour"]
|
||||
if idle.get("minutes") or idle.get("timeoutMinutes"):
|
||||
sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes")
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
@@ -2227,12 +2092,11 @@ class Migrator:
|
||||
browser_hermes = hermes_cfg.get("browser") or {}
|
||||
changed = False
|
||||
|
||||
# Map fields that have Hermes equivalents
|
||||
if browser.get("cdpUrl"):
|
||||
browser_hermes["cdp_url"] = browser["cdpUrl"]
|
||||
if browser.get("inactivityTimeoutMs"):
|
||||
browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000
|
||||
changed = True
|
||||
if browser.get("headless") is not None:
|
||||
browser_hermes["headless"] = browser["headless"]
|
||||
if browser.get("commandTimeoutMs"):
|
||||
browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
@@ -2243,9 +2107,9 @@ class Migrator:
|
||||
self.record("browser-config", "openclaw.json browser.*", "config.yaml browser",
|
||||
"migrated")
|
||||
|
||||
# Archive remaining browser settings
|
||||
# Archive advanced browser settings
|
||||
advanced = {k: v for k, v in browser.items()
|
||||
if k not in ("cdpUrl", "headless") and v}
|
||||
if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v}
|
||||
if advanced and self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2266,22 +2130,18 @@ class Migrator:
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
changed = False
|
||||
|
||||
# Map exec timeout -> terminal timeout (field is timeoutSec in OpenClaw)
|
||||
# Map exec timeout -> terminal timeout
|
||||
exec_cfg = tools.get("exec") or {}
|
||||
timeout_val = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")
|
||||
if timeout_val:
|
||||
if exec_cfg.get("timeout"):
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
terminal_cfg["timeout"] = timeout_val
|
||||
terminal_cfg["timeout"] = exec_cfg["timeout"]
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
changed = True
|
||||
|
||||
# Map web search API key (path: tools.web.search.brave.apiKey in OpenClaw)
|
||||
web_cfg = tools.get("web") or tools.get("webSearch") or {}
|
||||
search_cfg = web_cfg.get("search") or web_cfg if not web_cfg.get("search") else web_cfg["search"]
|
||||
brave_cfg = search_cfg.get("brave") or {}
|
||||
brave_key = brave_cfg.get("apiKey") or search_cfg.get("braveApiKey") or web_cfg.get("braveApiKey")
|
||||
if brave_key and isinstance(brave_key, str) and self.migrate_secrets:
|
||||
self._set_env_var("BRAVE_API_KEY", brave_key, "tools.web.search.brave.apiKey")
|
||||
# Map web search API key
|
||||
web_cfg = tools.get("webSearch") or tools.get("web") or {}
|
||||
if web_cfg.get("braveApiKey") and self.migrate_secrets:
|
||||
self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey")
|
||||
|
||||
if changed and self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
@@ -2309,9 +2169,8 @@ class Migrator:
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
|
||||
# Map approval mode (nested under approvals.exec.mode in OpenClaw)
|
||||
exec_approvals = approvals.get("exec") or {}
|
||||
mode = (exec_approvals.get("mode") if isinstance(exec_approvals, dict) else None) or approvals.get("mode") or approvals.get("defaultMode")
|
||||
# Map approval mode
|
||||
mode = approvals.get("mode") or approvals.get("defaultMode")
|
||||
if mode:
|
||||
mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"}
|
||||
hermes_mode = mode_map.get(mode, "manual")
|
||||
@@ -2455,24 +2314,9 @@ class Migrator:
|
||||
notes.append("")
|
||||
|
||||
notes.extend([
|
||||
"## IMPORTANT: Archive the OpenClaw Directory",
|
||||
"",
|
||||
"After migration, your OpenClaw directory still exists on disk with workspace",
|
||||
"state files (todo.json, sessions, logs). If the Hermes agent discovers these",
|
||||
"directories, it may read/write to them instead of the Hermes state, causing",
|
||||
"confusion (e.g., cron jobs reading a different todo list than interactive sessions).",
|
||||
"",
|
||||
"**Strongly recommended:** Run `hermes claw cleanup` to rename the OpenClaw",
|
||||
"directory to `.openclaw.pre-migration`. This prevents the agent from finding it.",
|
||||
"The directory is renamed, not deleted — you can undo this at any time.",
|
||||
"",
|
||||
"If you skip this step and notice the agent getting confused about workspaces",
|
||||
"or todo lists, run `hermes claw cleanup` to fix it.",
|
||||
"",
|
||||
"## Hermes-Specific Setup",
|
||||
"",
|
||||
"After migration, you may want to:",
|
||||
"- Run `hermes claw cleanup` to archive the OpenClaw directory (prevents state confusion)",
|
||||
"- Run `hermes setup` to configure any remaining settings",
|
||||
"- Run `hermes mcp list` to verify MCP servers were imported correctly",
|
||||
"- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)",
|
||||
|
||||
+1
-2
@@ -16,8 +16,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/NousResearch/Hermes-Agent#readme",
|
||||
"dependencies": {
|
||||
"agent-browser": "^0.13.0",
|
||||
"@askjo/camoufox-browser": "^1.0.0"
|
||||
"agent-browser": "^0.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
Homebrew packaging notes for Hermes Agent.
|
||||
|
||||
Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point.
|
||||
|
||||
Key choices:
|
||||
- Stable builds should target the semver-named sdist asset attached to each GitHub release, not the CalVer tag tarball.
|
||||
- `faster-whisper` now lives in the `voice` extra, which keeps wheel-only transitive dependencies out of the base Homebrew formula.
|
||||
- The wrapper exports `HERMES_BUNDLED_SKILLS`, `HERMES_OPTIONAL_SKILLS`, and `HERMES_MANAGED=homebrew` so packaged installs keep runtime assets and defer upgrades to Homebrew.
|
||||
|
||||
Typical update flow:
|
||||
1. Bump the formula `url`, `version`, and `sha256`.
|
||||
2. Refresh Python resources with `brew update-python-resources --print-only hermes-agent`.
|
||||
3. Keep `ignore_packages: %w[certifi cryptography pydantic]`.
|
||||
4. Verify `brew audit --new --strict hermes-agent` and `brew test hermes-agent`.
|
||||
@@ -1,48 +0,0 @@
|
||||
class HermesAgent < Formula
|
||||
include Language::Python::Virtualenv
|
||||
|
||||
desc "Self-improving AI agent that creates skills from experience"
|
||||
homepage "https://hermes-agent.nousresearch.com"
|
||||
# Stable source should point at the semver-named sdist asset attached by
|
||||
# scripts/release.py, not the CalVer tag tarball.
|
||||
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
|
||||
sha256 "<replace-with-release-asset-sha256>"
|
||||
license "MIT"
|
||||
|
||||
depends_on "certifi" => :no_linkage
|
||||
depends_on "cryptography" => :no_linkage
|
||||
depends_on "libyaml"
|
||||
depends_on "python@3.14"
|
||||
|
||||
pypi_packages ignore_packages: %w[certifi cryptography pydantic]
|
||||
|
||||
# Refresh resource stanzas after bumping the source url/version:
|
||||
# brew update-python-resources --print-only hermes-agent
|
||||
|
||||
def install
|
||||
venv = virtualenv_create(libexec, "python3.14")
|
||||
venv.pip_install resources
|
||||
venv.pip_install buildpath
|
||||
|
||||
pkgshare.install "skills", "optional-skills"
|
||||
|
||||
%w[hermes hermes-agent hermes-acp].each do |exe|
|
||||
next unless (libexec/"bin"/exe).exist?
|
||||
|
||||
(bin/exe).write_env_script(
|
||||
libexec/"bin"/exe,
|
||||
HERMES_BUNDLED_SKILLS: pkgshare/"skills",
|
||||
HERMES_OPTIONAL_SKILLS: pkgshare/"optional-skills",
|
||||
HERMES_MANAGED: "homebrew"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "Hermes Agent v#{version}", shell_output("#{bin}/hermes version")
|
||||
|
||||
managed = shell_output("#{bin}/hermes update 2>&1")
|
||||
assert_match "managed by Homebrew", managed
|
||||
assert_match "brew upgrade hermes-agent", managed
|
||||
end
|
||||
end
|
||||
+3
-11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"fal-client>=0.13.1,<1",
|
||||
# Text-to-speech (Edge TTS is free, no API key needed)
|
||||
"edge-tts>=7.2.7,<8",
|
||||
"faster-whisper>=1.0.0,<2",
|
||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||
]
|
||||
@@ -46,13 +47,7 @@ slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
matrix = ["matrix-nio[e2e]>=0.24.0,<1"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
voice = [
|
||||
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
||||
# so keep it out of the base install for source-build packagers like Homebrew.
|
||||
"faster-whisper>=1.0.0,<2",
|
||||
"sounddevice>=0.4.6,<1",
|
||||
"numpy>=1.24.0,<3",
|
||||
]
|
||||
voice = ["sounddevice>=0.4.6,<1", "numpy>=1.24.0,<3"]
|
||||
pty = [
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
||||
@@ -63,7 +58,6 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.8.1,<0.9"]
|
||||
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||
rl = [
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
||||
@@ -76,7 +70,6 @@ all = [
|
||||
"hermes-agent[modal]",
|
||||
"hermes-agent[daytona]",
|
||||
"hermes-agent[messaging]",
|
||||
"hermes-agent[matrix]",
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[dev]",
|
||||
@@ -90,7 +83,6 @@ all = [
|
||||
"hermes-agent[acp]",
|
||||
"hermes-agent[voice]",
|
||||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[feishu]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+13
-37
@@ -1285,7 +1285,7 @@ class AIAgent:
|
||||
try:
|
||||
fn = self._print_fn or print
|
||||
fn(*args, **kwargs)
|
||||
except (OSError, ValueError):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _vprint(self, *args, force: bool = False, **kwargs):
|
||||
@@ -2907,19 +2907,6 @@ class AIAgent:
|
||||
})
|
||||
return converted or None
|
||||
|
||||
@staticmethod
|
||||
def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
|
||||
"""Generate a deterministic call_id from tool call content.
|
||||
|
||||
Used as a fallback when the API doesn't provide a call_id.
|
||||
Deterministic IDs prevent cache invalidation — random UUIDs would
|
||||
make every API call's prefix unique, breaking OpenAI's prompt cache.
|
||||
"""
|
||||
import hashlib
|
||||
seed = f"{fn_name}:{arguments}:{index}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
|
||||
return f"call_{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Split a stored tool id into (call_id, response_item_id)."""
|
||||
@@ -3026,8 +3013,7 @@ class AIAgent:
|
||||
):
|
||||
call_id = f"call_{embedded_response_item_id[len('fc_'):]}"
|
||||
else:
|
||||
_raw_args = str(fn.get("arguments", "{}"))
|
||||
call_id = self._deterministic_call_id(fn_name, _raw_args, len(items))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
|
||||
arguments = fn.get("arguments", "{}")
|
||||
@@ -3391,7 +3377,7 @@ class AIAgent:
|
||||
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
|
||||
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
|
||||
@@ -3412,7 +3398,7 @@ class AIAgent:
|
||||
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
|
||||
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
|
||||
@@ -4947,10 +4933,7 @@ class AIAgent:
|
||||
if isinstance(raw_id, str) and raw_id.strip():
|
||||
call_id = raw_id.strip()
|
||||
else:
|
||||
_fn = getattr(tool_call, "function", None)
|
||||
_fn_name = getattr(_fn, "name", "") if _fn else ""
|
||||
_fn_args = getattr(_fn, "arguments", "{}") if _fn else "{}"
|
||||
call_id = self._deterministic_call_id(_fn_name, _fn_args, len(tool_calls))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
|
||||
response_item_id = getattr(tool_call, "response_item_id", None)
|
||||
@@ -5200,8 +5183,6 @@ class AIAgent:
|
||||
self._session_db.end_session(self.session_id, "compression")
|
||||
old_session_id = self.session_id
|
||||
self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
# Update session_log_file to point to the new session's JSON file
|
||||
self.session_log_file = self.logs_dir / f"session_{self.session_id}.json"
|
||||
self._session_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
@@ -5221,8 +5202,11 @@ class AIAgent:
|
||||
except Exception as e:
|
||||
logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
|
||||
|
||||
# Update token estimate after compaction so pressure calculations
|
||||
# use the post-compression count, not the stale pre-compression one.
|
||||
# Reset context pressure warning and token estimate — usage drops
|
||||
# after compaction. Without this, the stale last_prompt_tokens from
|
||||
# the previous API call causes the pressure calculation to stay at
|
||||
# >1000% and spam warnings / re-trigger compression in a loop.
|
||||
self._context_pressure_warned = False
|
||||
_compressed_est = (
|
||||
estimate_tokens_rough(new_system_prompt)
|
||||
+ estimate_messages_tokens_rough(compressed)
|
||||
@@ -5230,16 +5214,6 @@ class AIAgent:
|
||||
self.context_compressor.last_prompt_tokens = _compressed_est
|
||||
self.context_compressor.last_completion_tokens = 0
|
||||
|
||||
# Only reset the pressure warning if compression actually brought
|
||||
# us below the warning level (85% of threshold). When compression
|
||||
# can't reduce enough (e.g. threshold is very low, or system prompt
|
||||
# alone exceeds the warning level), keep the flag set to prevent
|
||||
# spamming the user with repeated warnings every loop iteration.
|
||||
if self.context_compressor.threshold_tokens > 0:
|
||||
_post_progress = _compressed_est / self.context_compressor.threshold_tokens
|
||||
if _post_progress < 0.85:
|
||||
self._context_pressure_warned = False
|
||||
|
||||
return compressed, new_system_prompt
|
||||
|
||||
def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
@@ -5686,6 +5660,8 @@ class AIAgent:
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
if len(preview) > 30:
|
||||
preview = preview[:27] + "..."
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn)
|
||||
spinner.start()
|
||||
_spinner_result = None
|
||||
@@ -7933,7 +7909,7 @@ class AIAgent:
|
||||
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
|
||||
try:
|
||||
print(f"❌ {error_msg}")
|
||||
except (OSError, ValueError):
|
||||
except OSError:
|
||||
logger.error(error_msg)
|
||||
|
||||
if self.verbose_logging:
|
||||
|
||||
+3
-15
@@ -94,7 +94,7 @@ print_banner() {
|
||||
echo ""
|
||||
echo -e "${MAGENTA}${BOLD}"
|
||||
echo "┌─────────────────────────────────────────────────────────┐"
|
||||
echo "│ ⚕ Hermes Agent Installer │"
|
||||
echo "│ ⚕ Hermes Agent Installer │"
|
||||
echo "├─────────────────────────────────────────────────────────┤"
|
||||
echo "│ An open source AI agent by Nous Research. │"
|
||||
echo "└─────────────────────────────────────────────────────────┘"
|
||||
@@ -699,19 +699,14 @@ install_deps() {
|
||||
|
||||
# Install the main package in editable mode with all extras.
|
||||
# Try [all] first, fall back to base install if extras have issues.
|
||||
ALL_INSTALL_LOG=$(mktemp)
|
||||
if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then
|
||||
if ! $UV_CMD pip install -e ".[all]" 2>/dev/null; then
|
||||
log_warn "Full install (.[all]) failed, trying base install..."
|
||||
log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)"
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
if ! $UV_CMD pip install -e "."; then
|
||||
log_error "Package installation failed."
|
||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
@@ -1075,14 +1070,7 @@ print_success() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}"
|
||||
echo ""
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
if [ "$LOGIN_SHELL" = "zsh" ]; then
|
||||
echo " source ~/.zshrc"
|
||||
elif [ "$LOGIN_SHELL" = "bash" ]; then
|
||||
echo " source ~/.bashrc"
|
||||
else
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
fi
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
echo ""
|
||||
|
||||
# Show Node.js warning if auto-install failed
|
||||
|
||||
+32
-124
@@ -24,7 +24,6 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
@@ -129,16 +128,6 @@ def git(*args, cwd=None):
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def git_result(*args, cwd=None):
|
||||
"""Run a git command and return the full CompletedProcess."""
|
||||
return subprocess.run(
|
||||
["git"] + list(args),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or str(REPO_ROOT),
|
||||
)
|
||||
|
||||
|
||||
def get_last_tag():
|
||||
"""Get the most recent CalVer tag."""
|
||||
tags = git("tag", "--list", "v20*", "--sort=-v:refname")
|
||||
@@ -147,18 +136,6 @@ def get_last_tag():
|
||||
return None
|
||||
|
||||
|
||||
def next_available_tag(base_tag: str) -> tuple[str, str]:
|
||||
"""Return a tag/calver pair, suffixing same-day releases when needed."""
|
||||
if not git("tag", "--list", base_tag):
|
||||
return base_tag, base_tag.removeprefix("v")
|
||||
|
||||
suffix = 2
|
||||
while git("tag", "--list", f"{base_tag}.{suffix}"):
|
||||
suffix += 1
|
||||
tag_name = f"{base_tag}.{suffix}"
|
||||
return tag_name, tag_name.removeprefix("v")
|
||||
|
||||
|
||||
def get_current_version():
|
||||
"""Read current semver from __init__.py."""
|
||||
content = VERSION_FILE.read_text()
|
||||
@@ -215,41 +192,6 @@ def update_version_files(semver: str, calver_date: str):
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
|
||||
|
||||
def build_release_artifacts(semver: str) -> list[Path]:
|
||||
"""Build sdist/wheel artifacts for the current release.
|
||||
|
||||
Returns the artifact paths when the local environment has ``python -m build``
|
||||
available. If build tooling is missing or the build fails, returns an empty
|
||||
list and lets the release proceed without attached Python artifacts.
|
||||
"""
|
||||
dist_dir = REPO_ROOT / "dist"
|
||||
shutil.rmtree(dist_dir, ignore_errors=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "build", "--sdist", "--wheel"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(" ⚠ Could not build Python release artifacts.")
|
||||
stderr = result.stderr.strip()
|
||||
stdout = result.stdout.strip()
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
elif stdout:
|
||||
print(f" {stdout.splitlines()[-1]}")
|
||||
print(" Install the 'build' package to attach semver-named sdist/wheel assets.")
|
||||
return []
|
||||
|
||||
artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
|
||||
matching = [p for p in artifacts if semver in p.name]
|
||||
if not matching:
|
||||
print(" ⚠ Built artifacts did not match the expected release version.")
|
||||
return []
|
||||
return matching
|
||||
|
||||
|
||||
def resolve_author(name: str, email: str) -> str:
|
||||
"""Resolve a git author to a GitHub @mention."""
|
||||
# Try email lookup first
|
||||
@@ -482,10 +424,18 @@ def main():
|
||||
now = datetime.now()
|
||||
calver_date = f"{now.year}.{now.month}.{now.day}"
|
||||
|
||||
base_tag = f"v{calver_date}"
|
||||
tag_name, calver_date = next_available_tag(base_tag)
|
||||
if tag_name != base_tag:
|
||||
print(f"Note: Tag {base_tag} already exists, using {tag_name}")
|
||||
tag_name = f"v{calver_date}"
|
||||
|
||||
# Check for existing tag with same date
|
||||
existing = git("tag", "--list", tag_name)
|
||||
if existing and not args.publish:
|
||||
# Append a suffix for same-day releases
|
||||
suffix = 2
|
||||
while git("tag", "--list", f"{tag_name}.{suffix}"):
|
||||
suffix += 1
|
||||
tag_name = f"{tag_name}.{suffix}"
|
||||
calver_date = f"{calver_date}.{suffix}"
|
||||
print(f"Note: Tag {tag_name[:-2]} already exists, using {tag_name}")
|
||||
|
||||
# Determine semver
|
||||
current_version = get_current_version()
|
||||
@@ -544,83 +494,41 @@ def main():
|
||||
print(f" ✓ Updated version files to v{new_version} ({calver_date})")
|
||||
|
||||
# Commit version bump
|
||||
add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE))
|
||||
if add_result.returncode != 0:
|
||||
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
|
||||
return
|
||||
|
||||
commit_result = git_result(
|
||||
"commit", "-m", f"chore: bump version to v{new_version} ({calver_date})"
|
||||
)
|
||||
if commit_result.returncode != 0:
|
||||
print(f" ✗ Failed to commit version bump: {commit_result.stderr.strip()}")
|
||||
return
|
||||
git("add", str(VERSION_FILE), str(PYPROJECT_FILE))
|
||||
git("commit", "-m", f"chore: bump version to v{new_version} ({calver_date})")
|
||||
print(f" ✓ Committed version bump")
|
||||
|
||||
# Create annotated tag
|
||||
tag_result = git_result(
|
||||
"tag", "-a", tag_name, "-m",
|
||||
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release"
|
||||
)
|
||||
if tag_result.returncode != 0:
|
||||
print(f" ✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}")
|
||||
return
|
||||
git("tag", "-a", tag_name, "-m",
|
||||
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release")
|
||||
print(f" ✓ Created tag {tag_name}")
|
||||
|
||||
# Push
|
||||
push_result = git_result("push", "origin", "HEAD", "--tags")
|
||||
if push_result.returncode == 0:
|
||||
print(f" ✓ Pushed to origin")
|
||||
else:
|
||||
print(f" ✗ Failed to push to origin: {push_result.stderr.strip()}")
|
||||
print(" Continue manually after fixing access:")
|
||||
print(" git push origin HEAD --tags")
|
||||
|
||||
# Build semver-named Python artifacts so downstream packagers
|
||||
# (e.g. Homebrew) can target them without relying on CalVer tag names.
|
||||
artifacts = build_release_artifacts(new_version)
|
||||
if artifacts:
|
||||
print(" ✓ Built release artifacts:")
|
||||
for artifact in artifacts:
|
||||
print(f" - {artifact.relative_to(REPO_ROOT)}")
|
||||
push_result = git("push", "origin", "HEAD", "--tags")
|
||||
print(f" ✓ Pushed to origin")
|
||||
|
||||
# Create GitHub release
|
||||
changelog_file = REPO_ROOT / ".release_notes.md"
|
||||
changelog_file.write_text(changelog)
|
||||
|
||||
gh_cmd = [
|
||||
"gh", "release", "create", tag_name,
|
||||
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
||||
"--notes-file", str(changelog_file),
|
||||
]
|
||||
gh_cmd.extend(str(path) for path in artifacts)
|
||||
result = subprocess.run(
|
||||
["gh", "release", "create", tag_name,
|
||||
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
||||
"--notes-file", str(changelog_file)],
|
||||
capture_output=True, text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
|
||||
gh_bin = shutil.which("gh")
|
||||
if gh_bin:
|
||||
result = subprocess.run(
|
||||
gh_cmd,
|
||||
capture_output=True, text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
else:
|
||||
result = None
|
||||
changelog_file.unlink(missing_ok=True)
|
||||
|
||||
if result and result.returncode == 0:
|
||||
changelog_file.unlink(missing_ok=True)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ GitHub release created: {result.stdout.strip()}")
|
||||
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!")
|
||||
else:
|
||||
if result is None:
|
||||
print(" ✗ GitHub release skipped: `gh` CLI not found.")
|
||||
else:
|
||||
print(f" ✗ GitHub release failed: {result.stderr.strip()}")
|
||||
print(f" Release notes kept at: {changelog_file}")
|
||||
print(f" Tag was created locally. Create the release manually:")
|
||||
print(
|
||||
f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' "
|
||||
f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}"
|
||||
)
|
||||
print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
|
||||
print(f" ✗ GitHub release failed: {result.stderr}")
|
||||
print(f" Tag was created. Create the release manually:")
|
||||
print(f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})'")
|
||||
|
||||
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!")
|
||||
else:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Dry run complete. To publish, add --publish")
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
export function normalizeWhatsAppIdentifier(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/:.*@/, '@')
|
||||
.replace(/@.*/, '')
|
||||
.replace(/^\+/, '');
|
||||
}
|
||||
|
||||
export function parseAllowedUsers(rawValue) {
|
||||
return new Set(
|
||||
String(rawValue || '')
|
||||
.split(',')
|
||||
.map((value) => normalizeWhatsAppIdentifier(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function readMappingFile(sessionDir, identifier, suffix = '') {
|
||||
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const normalized = normalizeWhatsAppIdentifier(parsed);
|
||||
return normalized || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
|
||||
const normalized = normalizeWhatsAppIdentifier(identifier);
|
||||
if (!normalized) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Walk both phone->LID and LID->phone mapping files so allowlists can use
|
||||
// either form transparently in bot mode.
|
||||
const resolved = new Set();
|
||||
const queue = [normalized];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || resolved.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.add(current);
|
||||
|
||||
for (const suffix of ['', '_reverse']) {
|
||||
const mapped = readMappingFile(sessionDir, current, suffix);
|
||||
if (mapped && !resolved.has(mapped)) {
|
||||
queue.push(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
|
||||
if (!allowedUsers || allowedUsers.size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
|
||||
for (const alias of aliases) {
|
||||
if (allowedUsers.has(alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
expandWhatsAppIdentifiers,
|
||||
matchesAllowedUser,
|
||||
normalizeWhatsAppIdentifier,
|
||||
parseAllowedUsers,
|
||||
} from './allowlist.js';
|
||||
|
||||
test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
|
||||
assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
|
||||
assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
|
||||
assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
|
||||
});
|
||||
|
||||
test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
|
||||
assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const allowedUsers = parseAllowedUsers('+19175395595');
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
|
||||
assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
|
||||
|
||||
// Parse CLI args
|
||||
const args = process.argv.slice(2);
|
||||
@@ -48,17 +47,13 @@ const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'docume
|
||||
const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
|
||||
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
||||
? DEFAULT_REPLY_PREFIX
|
||||
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
|
||||
|
||||
function formatOutgoingMessage(message) {
|
||||
// In bot mode, messages come from a different number so the prefix is
|
||||
// redundant — the sender identity is already clear. Only prepend in
|
||||
// self-chat mode where bot and user share the same number.
|
||||
if (WHATSAPP_MODE !== 'self-chat') return message;
|
||||
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
|
||||
}
|
||||
|
||||
@@ -195,9 +190,10 @@ async function startSocket() {
|
||||
if (!isSelfChat) continue;
|
||||
}
|
||||
|
||||
// Check allowlist for messages from others (resolve LID ↔ phone aliases)
|
||||
if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
continue;
|
||||
// Check allowlist for messages from others (resolve LID → phone if needed)
|
||||
if (!msg.key.fromMe && ALLOWED_USERS.length > 0) {
|
||||
const resolvedNumber = lidToPhone[senderNumber] || senderNumber;
|
||||
if (!ALLOWED_USERS.includes(resolvedNumber)) continue;
|
||||
}
|
||||
|
||||
// Extract message body
|
||||
@@ -519,8 +515,8 @@ if (PAIR_ONLY) {
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
|
||||
console.log(`📁 Session stored in: ${SESSION_DIR}`);
|
||||
if (ALLOWED_USERS.size > 0) {
|
||||
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
|
||||
if (ALLOWED_USERS.length > 0) {
|
||||
console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);
|
||||
} else {
|
||||
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
|
||||
}
|
||||
|
||||
@@ -744,149 +744,3 @@ class PixelBlendStack:
|
||||
result = blend_canvas(result, canvas, mode, opacity)
|
||||
return result
|
||||
```
|
||||
|
||||
## Text Backdrop (Readability Mask)
|
||||
|
||||
When placing readable text over busy multi-grid ASCII backgrounds, the text will blend into the background and become illegible. **Always apply a dark backdrop behind text regions.**
|
||||
|
||||
The technique: compute the bounding box of all text glyphs, create a gaussian-blurred dark mask covering that area with padding, and multiply the background by `(1 - mask * darkness)` before rendering text on top.
|
||||
|
||||
```python
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
def apply_text_backdrop(canvas, glyphs, padding=80, darkness=0.75):
|
||||
"""Darken the background behind text for readability.
|
||||
|
||||
Call AFTER rendering background, BEFORE rendering text.
|
||||
|
||||
Args:
|
||||
canvas: (VH, VW, 3) uint8 background
|
||||
glyphs: list of {"x": float, "y": float, ...} glyph positions
|
||||
padding: pixel padding around text bounding box
|
||||
darkness: 0.0 = no darkening, 1.0 = fully black
|
||||
Returns:
|
||||
darkened canvas (uint8)
|
||||
"""
|
||||
if not glyphs:
|
||||
return canvas
|
||||
xs = [g['x'] for g in glyphs]
|
||||
ys = [g['y'] for g in glyphs]
|
||||
x0 = max(0, int(min(xs)) - padding)
|
||||
y0 = max(0, int(min(ys)) - padding)
|
||||
x1 = min(VW, int(max(xs)) + padding + 50) # extra for char width
|
||||
y1 = min(VH, int(max(ys)) + padding + 60) # extra for char height
|
||||
|
||||
# Soft dark mask with gaussian blur for feathered edges
|
||||
mask = np.zeros((VH, VW), dtype=np.float32)
|
||||
mask[y0:y1, x0:x1] = 1.0
|
||||
mask = gaussian_filter(mask, sigma=padding * 0.6)
|
||||
|
||||
factor = 1.0 - mask * darkness
|
||||
return (canvas.astype(np.float32) * factor[:, :, np.newaxis]).astype(np.uint8)
|
||||
```
|
||||
|
||||
### Usage in render pipeline
|
||||
|
||||
Insert between background rendering and text rendering:
|
||||
|
||||
```python
|
||||
# 1. Render background (multi-grid ASCII effects)
|
||||
bg = render_background(cfg, t)
|
||||
|
||||
# 2. Darken behind text region
|
||||
bg = apply_text_backdrop(bg, frame_glyphs, padding=80, darkness=0.75)
|
||||
|
||||
# 3. Render text on top (now readable against dark backdrop)
|
||||
bg = text_renderer.render(bg, frame_glyphs, color=(255, 255, 255))
|
||||
```
|
||||
|
||||
Combine with **reverse vignette** (see shaders.md) for scenes where text is always centered — the reverse vignette provides a persistent center-dark zone, while the backdrop handles per-frame glyph positions.
|
||||
|
||||
## External Layout Oracle Pattern
|
||||
|
||||
For text-heavy videos where text needs to dynamically reflow around obstacles (shapes, icons, other text), use an external layout engine to pre-compute glyph positions and feed them into the Python renderer via JSON.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Layout Engine (browser/Node.js) → layouts.json → Python ASCII Renderer
|
||||
↑ ↑
|
||||
Computes per-frame Reads glyph positions,
|
||||
glyph (x,y) positions renders as ASCII chars
|
||||
with obstacle-aware reflow with full effect pipeline
|
||||
```
|
||||
|
||||
### JSON interchange format
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"canvas_width": 1080, "canvas_height": 1080,
|
||||
"fps": 24, "total_frames": 1248,
|
||||
"fonts": {
|
||||
"body": {"charW": 12.04, "charH": 24, "fontSize": 20},
|
||||
"hero": {"charW": 24.08, "charH": 48, "fontSize": 40}
|
||||
}
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"id": "scene_name",
|
||||
"start_frame": 0, "end_frame": 96,
|
||||
"frames": {
|
||||
"0": {
|
||||
"glyphs": [
|
||||
{"char": "H", "x": 287.1, "y": 400.0, "alpha": 1.0},
|
||||
{"char": "e", "x": 311.2, "y": 400.0, "alpha": 1.0}
|
||||
],
|
||||
"obstacles": [
|
||||
{"type": "circle", "cx": 540, "cy": 540, "r": 80},
|
||||
{"type": "rect", "x": 300, "y": 500, "w": 120, "h": 80}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### When to use
|
||||
|
||||
- Text that dynamically reflows around moving objects
|
||||
- Per-glyph animation (reveal, scatter, physics)
|
||||
- Variable typography that needs precise measurement
|
||||
- Any case where Python's Pillow text layout is insufficient
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- Static centered text (just use PIL `draw.text()` directly)
|
||||
- Text that only fades in/out without spatial animation
|
||||
- Simple typewriter effects (handle in Python with a character counter)
|
||||
|
||||
### Running the oracle
|
||||
|
||||
Use Playwright to run the layout engine in a headless browser:
|
||||
|
||||
```javascript
|
||||
// extract.mjs
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`file://${oraclePath}`);
|
||||
await page.waitForFunction(() => window.__ORACLE_DONE__ === true, null, { timeout: 60000 });
|
||||
const result = await page.evaluate(() => window.__ORACLE_RESULT__);
|
||||
writeFileSync('layouts.json', JSON.stringify(result));
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
### Consuming in Python
|
||||
|
||||
```python
|
||||
# In the renderer, map pixel positions to the canvas:
|
||||
for glyph in frame_data['glyphs']:
|
||||
char, px, py = glyph['char'], glyph['x'], glyph['y']
|
||||
alpha = glyph.get('alpha', 1.0)
|
||||
# Render using PIL draw.text() at exact pixel position
|
||||
draw.text((px, py), char, fill=(int(255*alpha),)*3, font=font)
|
||||
```
|
||||
|
||||
Obstacles from the JSON can also be rendered as glowing ASCII shapes (circles, rectangles) to visualize the reflow zones.
|
||||
|
||||
@@ -834,39 +834,6 @@ def sh_vignette(c, s=0.22):
|
||||
return np.clip(c * _vig_cache[k][:,:,None], 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
#### Reverse Vignette
|
||||
|
||||
Inverted vignette: darkens the **center** and leaves edges bright. Useful when text is centered over busy backgrounds — creates a natural dark zone for readability without a hard-edged box.
|
||||
|
||||
Combine with `apply_text_backdrop()` (see composition.md) for per-frame glyph-aware darkening.
|
||||
|
||||
```python
|
||||
_rvignette_cache = {}
|
||||
|
||||
def sh_reverse_vignette(c, strength=0.5):
|
||||
"""Center darkening, edge brightening. Cached."""
|
||||
k = ('rv', c.shape[0], c.shape[1], round(strength, 2))
|
||||
if k not in _rvignette_cache:
|
||||
h, w = c.shape[:2]
|
||||
Y = np.linspace(-1, 1, h)[:, None]
|
||||
X = np.linspace(-1, 1, w)[None, :]
|
||||
d = np.sqrt(X**2 + Y**2)
|
||||
# Invert: bright at edges, dark at center
|
||||
mask = np.clip(1.0 - (1.0 - d * 0.7) * strength, 0.2, 1.0)
|
||||
_rvignette_cache[k] = mask[:, :, np.newaxis].astype(np.float32)
|
||||
return np.clip(c.astype(np.float32) * _rvignette_cache[k], 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
| Param | Default | Effect |
|
||||
|-------|---------|--------|
|
||||
| `strength` | 0.5 | 0 = no effect, 1.0 = center nearly black |
|
||||
|
||||
Add to ShaderChain dispatch:
|
||||
```python
|
||||
elif name == "reverse_vignette":
|
||||
return sh_reverse_vignette(canvas, kwargs.get("strength", 0.5))
|
||||
```
|
||||
|
||||
#### Contrast
|
||||
```python
|
||||
def sh_contrast(c, factor=1.3):
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
| Random dark holes in output | Font missing Unicode glyphs | Validate palettes at init |
|
||||
| Audio-visual desync | Frame timing accumulation | Use integer frame counter, compute t fresh each frame |
|
||||
| Single-color flat output | Hue field shape mismatch | Ensure h,s,v arrays all (rows,cols) before hsv2rgb |
|
||||
| Text unreadable over busy bg | No contrast between text and background | Use `apply_text_backdrop()` (composition.md) + `reverse_vignette` shader (shaders.md) |
|
||||
| Text garbled/mirrored | Kaleidoscope or mirror shader applied to text scene | **Never apply kaleidoscope, mirror_h/v/quad/diag to scenes with readable text** — radial folding destroys legibility. Apply these only to background layers or text-free scenes |
|
||||
|
||||
Common bugs, gotchas, and platform-specific issues encountered during ASCII video development.
|
||||
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
---
|
||||
name: songwriting-and-ai-music
|
||||
description: >
|
||||
Songwriting craft, AI music generation prompts (Suno focus), parody/adaptation
|
||||
techniques, phonetic tricks, and lessons learned. These are tools and ideas,
|
||||
not rules. Break any of them when the art calls for it.
|
||||
tags: [songwriting, music, suno, parody, lyrics, creative]
|
||||
triggers:
|
||||
- writing a song
|
||||
- song lyrics
|
||||
- music prompt
|
||||
- suno prompt
|
||||
- parody song
|
||||
- adapting a song
|
||||
- AI music generation
|
||||
---
|
||||
|
||||
# Songwriting & AI Music Generation
|
||||
|
||||
Everything here is a GUIDELINE, not a rule. Art breaks rules on purpose.
|
||||
Use what serves the song. Ignore what doesn't.
|
||||
|
||||
---
|
||||
|
||||
## 1. Song Structure (Pick One or Invent Your Own)
|
||||
|
||||
Common skeletons — mix, modify, or throw out as needed:
|
||||
|
||||
```
|
||||
ABABCB Verse/Chorus/Verse/Chorus/Bridge/Chorus (most pop/rock)
|
||||
AABA Verse/Verse/Bridge/Verse (refrain-based) (jazz standards, ballads)
|
||||
ABAB Verse/Chorus alternating (simple, direct)
|
||||
AAA Verse/Verse/Verse (strophic, no chorus) (folk, storytelling)
|
||||
```
|
||||
|
||||
The six building blocks:
|
||||
- Intro — set the mood, pull the listener in
|
||||
- Verse — the story, the details, the world-building
|
||||
- Pre-Chorus — optional tension ramp before the payoff
|
||||
- Chorus — the emotional core, the part people remember
|
||||
- Bridge — a detour, a shift in perspective or key
|
||||
- Outro — the farewell, can echo or subvert the rest
|
||||
|
||||
You don't need all of these. Some great songs are just one section
|
||||
that evolves. Structure serves the emotion, not the other way around.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rhyme, Meter, and Sound
|
||||
|
||||
RHYME TYPES (from tight to loose):
|
||||
- Perfect: lean/mean
|
||||
- Family: crate/braid
|
||||
- Assonance: had/glass (same vowels, different endings)
|
||||
- Consonance: scene/when (different vowels, similar endings)
|
||||
- Near/slant: enough to suggest connection without locking it down
|
||||
|
||||
Mix them. All perfect rhymes can sound like a nursery rhyme.
|
||||
All slant rhymes can sound lazy. The blend is where it lives.
|
||||
|
||||
INTERNAL RHYME: Rhyming within a line, not just at the ends.
|
||||
"We pruned the lies from bleeding trees / Distilled the storm
|
||||
from entropy" — "lies/flies," "trees/entropy" create internal echoes.
|
||||
|
||||
METER: The rhythm of stressed vs unstressed syllables.
|
||||
- Matching syllable counts between parallel lines helps singability
|
||||
- The STRESSED syllables matter more than total count
|
||||
- Say it out loud. If you stumble, the meter needs work.
|
||||
- Intentionally breaking meter can create emphasis or surprise
|
||||
|
||||
---
|
||||
|
||||
## 3. Emotional Arc and Dynamics
|
||||
|
||||
Think of a song as a journey, not a flat road.
|
||||
|
||||
ENERGY MAPPING (rough idea, not prescription):
|
||||
Intro: 2-3 | Verse: 5-6 | Pre-Chorus: 7
|
||||
Chorus: 8-9 | Bridge: varies | Final Chorus: 9-10
|
||||
|
||||
The most powerful dynamic trick: CONTRAST.
|
||||
- Whisper before a scream hits harder than just screaming
|
||||
- Sparse before dense. Slow before fast. Low before high.
|
||||
- The drop only works because of the buildup
|
||||
- Silence is an instrument
|
||||
|
||||
"Whisper to roar to whisper" — start intimate, build to full power,
|
||||
strip back to vulnerability. Works for ballads, epics, anthems.
|
||||
|
||||
---
|
||||
|
||||
## 4. Writing Lyrics That Work
|
||||
|
||||
SHOW, DON'T TELL (usually):
|
||||
- "I was sad" = flat
|
||||
- "Your hoodie's still on the hook by the door" = alive
|
||||
- But sometimes "I give my life" said plainly IS the power
|
||||
|
||||
THE HOOK:
|
||||
- The line people remember, hum, repeat
|
||||
- Usually the title or core phrase
|
||||
- Works best when melody + lyric + emotion all align
|
||||
- Place it where it lands hardest (often first/last line of chorus)
|
||||
|
||||
PROSODY — lyrics and music supporting each other:
|
||||
- Stable feelings (resolution, peace) pair with settled melodies,
|
||||
perfect rhymes, resolved chords
|
||||
- Unstable feelings (longing, doubt) pair with wandering melodies,
|
||||
near-rhymes, unresolved chords
|
||||
- Verse melody typically sits lower, chorus goes higher
|
||||
- But flip this if it serves the song
|
||||
|
||||
AVOID (unless you're doing it on purpose):
|
||||
- Cliches on autopilot ("heart of gold" without earning it)
|
||||
- Forcing word order to hit a rhyme ("Yoda-speak")
|
||||
- Same energy in every section (flat dynamics)
|
||||
- Treating your first draft as sacred — revision is creation
|
||||
|
||||
---
|
||||
|
||||
## 5. Parody and Adaptation
|
||||
|
||||
When rewriting an existing song with new lyrics:
|
||||
|
||||
THE SKELETON: Map the original's structure first.
|
||||
- Count syllables per line
|
||||
- Mark the rhyme scheme (ABAB, AABB, etc.)
|
||||
- Identify which syllables are STRESSED
|
||||
- Note where held/sustained notes fall
|
||||
|
||||
FITTING NEW WORDS:
|
||||
- Match stressed syllables to the same beats as the original
|
||||
- Total syllable count can flex by 1-2 unstressed syllables
|
||||
- On long held notes, try to match the VOWEL SOUND of the original
|
||||
(if original holds "LOOOVE" with an "oo" vowel, "FOOOD" fits
|
||||
better than "LIFE")
|
||||
- Monosyllabic swaps in key spots keep rhythm intact
|
||||
(Crime -> Code, Snake -> Noose)
|
||||
- Sing your new words over the original — if you stumble, revise
|
||||
|
||||
CONCEPT:
|
||||
- Pick a concept strong enough to sustain the whole song
|
||||
- Start from the title/hook and build outward
|
||||
- Generate lots of raw material (puns, phrases, images) FIRST,
|
||||
then fit the best ones into the structure
|
||||
- If you need a specific line somewhere, reverse-engineer the
|
||||
rhyme scheme backward to set it up
|
||||
|
||||
KEEP SOME ORIGINALS: Leaving a few original lines or structures
|
||||
intact adds recognizability and lets the audience feel the connection.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suno AI Prompt Engineering
|
||||
|
||||
### Style/Genre Description Field
|
||||
|
||||
FORMULA (adapt as needed):
|
||||
Genre + Mood + Era + Instruments + Vocal Style + Production + Dynamics
|
||||
|
||||
```
|
||||
BAD: "sad rock song"
|
||||
GOOD: "Cinematic orchestral spy thriller, 1960s Cold War era, smoky
|
||||
sultry female vocalist, big band jazz, brass section with
|
||||
trumpets and french horns, sweeping strings, minor key,
|
||||
vintage analog warmth"
|
||||
```
|
||||
|
||||
DESCRIBE THE JOURNEY, not just the genre:
|
||||
```
|
||||
"Begins as a haunting whisper over sparse piano. Gradually layers
|
||||
in muted brass. Builds through the chorus with full orchestra.
|
||||
Second verse erupts with raw belting intensity. Outro strips back
|
||||
to a lone piano and a fragile whisper fading to silence."
|
||||
```
|
||||
|
||||
TIPS:
|
||||
- V4.5+ supports up to 1,000 chars in Style field — use them
|
||||
- NO artist names or trademarks. Describe the sound instead.
|
||||
"1960s Cold War spy thriller brass" not "James Bond style"
|
||||
"90s grunge" not "Nirvana-style"
|
||||
- Specify BPM and key when you have a preference
|
||||
- Use Exclude Styles field for what you DON'T want
|
||||
- Unexpected genre combos can be gold: "bossa nova trap",
|
||||
"Appalachian gothic", "chiptune jazz"
|
||||
- Build a vocal PERSONA, not just a gender:
|
||||
"A weathered torch singer with a smoky alto, slight rasp,
|
||||
who starts vulnerable and builds to devastating power"
|
||||
|
||||
### Metatags (place in [brackets] inside lyrics field)
|
||||
|
||||
STRUCTURE:
|
||||
[Intro] [Verse] [Verse 1] [Pre-Chorus] [Chorus]
|
||||
[Post-Chorus] [Hook] [Bridge] [Interlude]
|
||||
[Instrumental] [Instrumental Break] [Guitar Solo]
|
||||
[Breakdown] [Build-up] [Outro] [Silence] [End]
|
||||
|
||||
VOCAL PERFORMANCE:
|
||||
[Whispered] [Spoken Word] [Belted] [Falsetto] [Powerful]
|
||||
[Soulful] [Raspy] [Breathy] [Smooth] [Gritty]
|
||||
[Staccato] [Legato] [Vibrato] [Melismatic]
|
||||
[Harmonies] [Choir] [Harmonized Chorus]
|
||||
|
||||
DYNAMICS:
|
||||
[High Energy] [Low Energy] [Building Energy] [Explosive]
|
||||
[Emotional Climax] [Gradual swell] [Orchestral swell]
|
||||
[Quiet arrangement] [Falling tension] [Slow Down]
|
||||
|
||||
GENDER:
|
||||
[Female Vocals] [Male Vocals]
|
||||
|
||||
ATMOSPHERE:
|
||||
[Melancholic] [Euphoric] [Nostalgic] [Aggressive]
|
||||
[Dreamy] [Intimate] [Dark Atmosphere]
|
||||
|
||||
SFX:
|
||||
[Vinyl Crackle] [Rain] [Applause] [Static] [Thunder]
|
||||
|
||||
Put tags in BOTH style field AND lyrics for reinforcement.
|
||||
Keep to 5-8 tags per section max — too many confuses the AI.
|
||||
Don't contradict yourself ([Calm] + [Aggressive] in same section).
|
||||
|
||||
### Custom Mode
|
||||
- Always use Custom Mode for serious work (separate Style + Lyrics)
|
||||
- Lyrics field limit: ~3,000 chars (~40-60 lines)
|
||||
- Always add structural tags — without them Suno defaults to
|
||||
flat verse/chorus/verse with no emotional arc
|
||||
|
||||
---
|
||||
|
||||
## 7. Phonetic Tricks for AI Singers
|
||||
|
||||
AI vocalists don't read — they pronounce. Help them:
|
||||
|
||||
PHONETIC RESPELLING:
|
||||
- Spell words as they SOUND: "through" -> "thru"
|
||||
- Proper nouns are highest failure rate — test early
|
||||
- "Nous" -> "Noose" (forces correct pronunciation)
|
||||
- Hyphenate to guide syllables: "Re-search", "bio-engineering"
|
||||
|
||||
DELIVERY CONTROL:
|
||||
- ALL CAPS = louder, more intense
|
||||
- Vowel extension: "lo-o-o-ove" = sustained/melisma
|
||||
- Ellipses: "I... need... you" = dramatic pauses
|
||||
- Hyphenated stretch: "ne-e-ed" = emotional stretch
|
||||
|
||||
ALWAYS:
|
||||
- Spell out numbers: "24/7" -> "twenty four seven"
|
||||
- Space acronyms: "AI" -> "A I" or "A-I"
|
||||
- Test proper nouns/unusual words in a short 30-second clip first
|
||||
- Once generated, pronunciation is baked in — fix in lyrics BEFORE
|
||||
|
||||
---
|
||||
|
||||
## 8. Workflow
|
||||
|
||||
1. Write the concept/hook first — what's the emotional core?
|
||||
2. If adapting, map the original structure (syllables, rhyme, stress)
|
||||
3. Generate raw material — brainstorm freely before structuring
|
||||
4. Draft lyrics into the structure
|
||||
5. Read/sing aloud — catch stumbles, fix meter
|
||||
6. Build the Suno style description — paint the dynamic journey
|
||||
7. Add metatags to lyrics for performance direction
|
||||
8. Generate 3-5 variations minimum — treat them like recording takes
|
||||
9. Pick the best, use Extend/Continue to build on promising sections
|
||||
10. If something great happens by accident, keep it
|
||||
|
||||
EXPECT: ~3-5 generations per 1 good result. Revision is normal.
|
||||
Style can drift in extensions — restate genre/mood when extending.
|
||||
|
||||
---
|
||||
|
||||
## 9. Lessons Learned
|
||||
|
||||
- Describing the dynamic ARC in the style field matters way more
|
||||
than just listing genres. "Whisper to roar to whisper" gives
|
||||
Suno a performance map.
|
||||
- Keeping some original lines intact in a parody adds recognizability
|
||||
and emotional weight — the audience feels the ghost of the original.
|
||||
- The bridge slot in a song is where you can transform imagery.
|
||||
Swap the original's specific references for your theme's metaphors
|
||||
while keeping the emotional function (reflection, shift, revelation).
|
||||
- Monosyllabic word swaps in hooks/tags are the cleanest way to
|
||||
maintain rhythm while changing meaning.
|
||||
- A strong vocal persona description in the style field makes a
|
||||
bigger difference than any single metatag.
|
||||
- Don't be precious about rules. If a line breaks meter but hits
|
||||
harder, keep it. The feeling is what matters. Craft serves art,
|
||||
not the other way around.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user